Firmware DIY untuk printer LCD 3D photopolymer. Bagian 3





Di dua bagian sebelumnya, saya berbicara tentang bagaimana saya membuat GUI, mulai mengendalikan motor stepper dan mengatur pekerjaan dengan file pada USB flash drive.



Hari ini saya akan menulis tentang proses pencetakan, keluaran dari lapisan yang dicetak ke layar sorotan dan sisanya, hal-hal yang tidak terlalu penting:



4. Keluaran gambar dari lapisan ke tampilan sorotan.

5. Setiap hal kecil, seperti mengontrol pencahayaan dan kipas, pengaturan pemuatan dan penyimpanan, dll.

6. Fitur tambahan untuk kenyamanan dan kemudahan.





4.



4.1 -



Bagaimana mikrokontroler, yang tidak memiliki periferal khusus, dapat membuat gambar pada matriks resolusi tinggi dengan kecepatan 74 juta piksel per detik (resolusi 2560x1440, 20 frame per detik) diperbarui melalui antarmuka MIPI? Jawaban: menggunakan FPGA dengan 16MB SDRAM terhubung dan dua chip antarmuka MIPI - SSD2828. Dua sirkuit mikro berharga karena tampilan secara logis dibagi menjadi dua bagian, yang masing-masing dilayani oleh salurannya sendiri yang terpisah, ternyata dua layar menjadi satu.



Gambar untuk tampilan disimpan di salah satu dari 4 bank SDRAM, chip FPGA bertanggung jawab untuk memperbaiki SDRAM dan mengeluarkan gambar dari SDRAM tersebut ke SSD2828. FPGA menghasilkan sinyal sinkronisasi vertikal dan horizontal untuk SSD2828 dan drive

aliran nilai warna yang berkelanjutan untuk piksel lebih dari 24 baris (8R 8G 8B) ke masing-masing SSD2828. Kecepatan frame ternyata sekitar 20 Hz.



FPGA terhubung ke mikrokontroler dengan antarmuka serial (SPI) di mana mikrokontroler dapat mengirimkan gambar. Ditransmisikan dalam paket, yang masing-masing berisi satu baris gambar (garis dihitung di sepanjang sisi pendek layar - 1440 piksel). Selain data ini, paket juga berisi nomor bank SDRAM, nomor baris, dan checksum - CRC16. FPGA menerima paket ini, memeriksa checksum, dan jika semuanya beres, menyimpan data ke area SDRAM yang sesuai. Jika CRC tidak cocok, FPGA menetapkan sinyal pada salah satu pinnya, juga terhubung ke mikrokontroler, yang menurutnya mikrokontroler memahami bahwa data tidak mencapai normal dan dapat mengulangi pengiriman. Untuk gambar yang lengkap, mikrokontroler harus mengirimkan 2560 paket tersebut ke FPGA.



Data gambar di dalam paket direpresentasikan dalam format bit: 1 - piksel menyala, 0 - piksel gelap. Sayangnya, ini sepenuhnya mengecualikan kemungkinan mengatur buram skala abu-abu pada tepi lapisan yang dicetak - anti-aliasing. Untuk mengatur cara pengaburan ini, perlu menulis ulang konfigurasi (firmware) dari FPGA, yang saya belum siap. Sudah terlalu lama dan tidak terlalu lama saya telah bekerja dengan FPGA, saya harus menguasai kembali semuanya.



Selain paket data, mikrokontroler juga dapat mengirimkan perintah kontrol untuk menunjukkan dari bank SDRAM mana yang membaca data untuk keluaran ke tampilan dan mengaktifkan / menonaktifkan keluaran gambar.



Chip SSD2828 juga dihubungkan ke mikrokontroler melalui SPI. Ini diperlukan untuk mengonfigurasi register mereka saat dihidupkan, mentransfernya ke mode tidur atau aktif.

Ada beberapa garis lagi antara mikrokontroler dan FPGA / SSD2828 - sinyal reset dan sinyal pilih chip aktif (Chip Select) untuk masing-masing sirkuit mikro.



Secara umum, skema kerja ini agak jauh dari optimal menurut saya. Misalnya, akan lebih logis untuk menghubungkan FPGA ke mikrokontroler melalui antarmuka memori eksternal paralel, data akan ditransfer lebih cepat daripada melalui SPI dengan batas frekuensi 20 MHz (ketika frekuensi naik, FPGA berhenti menerima data secara normal). Plus, sinyal reset tidak diarahkan ke input Reset FPGA fisik, tetapi sebagai sinyal logis normal, yaitu, FPGA tidak melakukan reset perangkat keras padanya. Dan ini juga memainkan lelucon yang kejam, yang akan dibahas di bawah.



Saya menemukan semua ini dengan memahami kode sumber pabrikan. Saya mentransfer fungsi bekerja dengan FPGA dari kode sumber mereka sebagaimana adanya, saya masih tidak sepenuhnya memahami bagaimana semuanya bekerja. Untungnya, orang Cina telah cukup mengomentari kode mereka (dalam bahasa Cina) untuk dapat memahaminya tanpa banyak kesulitan.



4.2 Membaca lapisan dari file cetak



Ok, kurang lebih kita sudah mengetahui keluaran dari gambar yang sudah jadi, sekarang saya akan memberi tahu Anda sedikit tentang bagaimana gambar-gambar ini diekstraksi dari file yang disiapkan untuk dicetak. File .Pws, .photons, .photon, .cbddlp pada dasarnya adalah sekumpulan gambar lapisan. Format ini, sejauh yang saya tahu, berasal dari perusahaan Cina Chitu, yang muncul dengan ide membuat papan dengan sirkuit seperti itu (mikrokontroler - FPGA - SDRAM - SSD2828). Misalkan Anda ingin mencetak model dengan tinggi 30 mm dengan setiap lapisan tebal 0,05 mm. Program pengiris memotong model ini menjadi lapisan dengan ketebalan yang ditentukan dan untuk masing-masing membentuk gambarnya.



Dengan demikian, diperoleh 30 / 0,05 = 600 gambar dengan resolusi 1440x2560. Gambar-gambar ini dikemas ke dalam file keluaran, header dengan semua parameter dimasukkan di sana, dan file semacam itu sudah dikirim ke printer. Gambar lapisan memiliki kedalaman 1-bit dan dikompresi oleh algoritma RLE satu byte pada satu waktu, dengan bit paling signifikan menunjukkan nilai warna dan tujuh bit paling signifikan menunjukkan jumlah pengulangan. Metode ini memungkinkan Anda untuk memampatkan gambar lapisan dari 460 KB menjadi sekitar 30-50. Printer membaca lapisan yang dikompresi, mendekompresi, dan mengirimkannya baris demi baris ke FPGA.



Pabrikan melakukan ini sebagai berikut:



  1. — 1, 1, 0. , (1440), .
  2. , 1440 (180 ).
  3. FPGA .


Ini adalah metode tiga langkah yang digunakan oleh orang Cina. Ternyata, ini dilakukan agar gambar dari lapisan dapat ditampilkan dalam bentuk yang diperkecil pada tampilan antarmuka, yang menunjukkan kepada pengguna apa yang sedang dicetak. Gambar ini baru terbentuk dari array byte. Meskipun apa yang mencegah pembentukannya segera dari bit yang didekodekan masih belum jelas. Dan apa yang mencegah pembentukan bitmap untuk ditransfer ke FPGA dalam siklus yang sama juga tidak jelas.



Sekarang saya menggunakan metode yang sama, meskipun dioptimalkan. Untuk memperjelas apa itu pengoptimalan, saya perlu memperjelas satu hal lagi. Data untuk garis tampilan bukanlah rangkaian muatan yang solid. Di tengah ada beberapa piksel "tidak berfungsi" tambahan karena fakta bahwa dua pengontrol tampilan digabungkan di sisi pendek, dan masing-masing memiliki 24 piksel "tidak berfungsi" di tepinya. Jadi, data sebenarnya yang ditransmisikan untuk satu baris gambar terdiri dari 3 bagian: data untuk paruh pertama (pengontrol pertama), data untuk paruh kedua "tidak berfungsi" 48 piksel, data untuk paruh kedua (pengontrol kedua).



Jadi, orang Cina, ketika membentuk array byte di dalam loop, memeriksa apakah akhir dari paruh pertama tercapai, jika tidak, maka nilainya ditulis oleh penunjuk * p, sebaliknya dengan penunjuk * (p + 48) . Pemeriksaan ini untuk masing-masing nilai 1440, dan bahkan modifikasi penunjuk untuk setengahnya, jelas tidak berkontribusi pada kecepatan loop. Saya membagi satu loop ini menjadi dua yang terpisah - di bagian pertama, paruh pertama array diisi, setelah loop ini, penunjuk dinaikkan sebesar 48 dan loop kedua dimulai untuk paruh kedua array. Dalam versi aslinya, lapisan dibaca dan ditampilkan dalam 1,9 detik, modifikasi ini sendiri mengurangi waktu baca dan keluaran menjadi 1,2 detik.



Perubahan lain menyangkut transfer data ke FPGA. Dalam sumber aslinya, ini terjadi melalui DMA, tetapi setelah dimulainya transfer melalui DMA, fungsi tersebut menunggu hingga selesai dan baru setelah itu mulai memecahkan kode dan membentuk baris baru dari gambar. Saya menghapus harapan ini sehingga baris berikutnya dibuat saat data dari baris sebelumnya sedang ditransfer. Ini mengurangi waktu 0,3 detik lagi, menjadi 0,9 per lapisan. Dan ini adalah saat kompilasi tanpa optimasi, jika Anda mengkompilasi dengan optimasi penuh, maka waktunya berkurang menjadi sekitar 0,53 detik, yang sudah cukup dapat diterima. Dari 0,53 detik ini, dibutuhkan sekitar 0,22 detik untuk menghitung CRC16 dan sekitar 0,19 detik untuk membentuk bitmap dari array byte sebelum transmisi. Tetapi transfer semua jalur ke FPGA itu sendiri membutuhkan waktu sekitar 0,4 detik dan dengan ini, kemungkinan besar,tidak ada yang bisa dilakukan - semua yang ada di sini bertumpu pada batasan frekuensi SPI maksimum yang diperbolehkan untuk FPGA.



Jika saya dapat menulis konfigurasi FPGA sendiri, saya dapat memberikannya dekompresi RLE, dan ini dapat mempercepat keluaran lapisan berdasarkan urutan besarnya, tetapi bagaimana cara melakukannya?



Dan ya, saya akan menulis tentang kusen yang terkait dengan fakta bahwa FPGA tidak diatur ulang oleh perangkat keras pada sinyal reset dari mikrokontroler. Jadi, ketika saya sudah mempelajari cara menampilkan gambar lapisan, menyelesaikan proses pencetakan itu sendiri, saya mengalami bug yang tidak dapat dipahami - sekali dari 5-10 pencetakan dimulai dengan tampilan yang menyala sepenuhnya. Saya melihat di debugger bahwa lapisan dibaca dengan benar, data dikirim ke FPGA sesuai kebutuhan, FPGA menegaskan kebenaran CRC. Artinya, semuanya berfungsi, dan alih-alih menggambar lapisan, tampilan sepenuhnya putih. Jelas FPGA atau SSD2828 yang harus disalahkan. Sekali lagi saya memeriksa ulang inisialisasi SSD2828 - semuanya baik-baik saja, semua register di dalamnya diinisialisasi dengan nilai yang diperlukan, ini dapat dilihat selama pembacaan kontrol nilai dari mereka. Lalu saya sudah meraih papan dengan osiloskop. Dan saya menemukan bahwa ketika kegagalan seperti itu terjadi, FPGA tidak menulis data apa pun ke SDRAM. KAMI memberi sinyal,mengizinkan penulisan, berdiri kokoh di tempat di tingkat tidak aktif. Dan saya mungkin akan berjuang dengan kesalahan ini untuk waktu yang lama, jika bukan karena teman yang menyarankan saya untuk mencoba memberikan perintah eksplisit pada FPGA untuk mematikan keluaran gambar sebelum mengatur ulang, sehingga pada saat pengaturan ulang dijamin tidak ada panggilan dari FPGA ke SDRAM. Saya mencobanya dan berhasil! Bug ini tidak pernah muncul lagi. Pada akhirnya, kami sampai pada kesimpulan bahwa inti IP pengontrol SDRAM di dalam FPGA tidak diterapkan dengan benar, reset dan inisialisasi pengontrol SDRAM tidak terjadi secara normal di semua kasus. Sesuatu mengganggu reset yang benar jika data di SDRAM diakses saat ini. Seperti ini…yang menyarankan untuk mencoba sebelum reset memberikan FPGA perintah eksplisit untuk mematikan output gambar, sehingga pada saat reset dijamin tidak ada panggilan dari FPGA ke SDRAM. Saya mencobanya dan berhasil! Bug ini tidak pernah muncul lagi. Pada akhirnya, kami sampai pada kesimpulan bahwa inti IP pengontrol SDRAM di dalam FPGA tidak diterapkan dengan benar, reset dan inisialisasi pengontrol SDRAM tidak terjadi secara normal di semua kasus. Sesuatu mengganggu reset yang benar jika data di SDRAM diakses saat ini. Seperti ini…yang menyarankan untuk mencoba sebelum reset memberikan FPGA perintah eksplisit untuk mematikan output gambar, sehingga pada saat reset dijamin tidak ada panggilan dari FPGA ke SDRAM. Saya mencobanya dan berhasil! Bug ini tidak pernah muncul lagi. Pada akhirnya, kami sampai pada kesimpulan bahwa inti IP pengontrol SDRAM di dalam FPGA tidak diterapkan dengan benar, reset dan inisialisasi pengontrol SDRAM tidak terjadi secara normal di semua kasus. Sesuatu mengganggu reset yang benar jika data di SDRAM diakses saat ini. Seperti ini…bahwa inti-IP pengontrol SDRAM di dalam FPGA tidak diterapkan dengan benar, pengaturan ulang dan inisialisasi pengontrol SDRAM tidak berfungsi normal di semua kasus. Sesuatu mengganggu reset yang benar jika data di SDRAM diakses saat ini. Seperti ini…bahwa inti IP dari pengontrol SDRAM di dalam FPGA tidak diterapkan dengan benar, pengaturan ulang dan inisialisasi pengontrol SDRAM tidak bekerja secara normal di semua kasus. Sesuatu mengganggu reset yang benar jika data di SDRAM diakses saat ini. Seperti ini…



4.3 Antarmuka pengguna selama pencetakan file



Setelah pengguna memilih file dan mulai mencetaknya, layar berikut akan muncul:







Ini adalah layar yang cukup standar untuk printer photopolymer tersebut.



Area terbesar dari layar ditempati oleh gambar dari lapisan yang saat ini terbuka.

Tampilan gambar ini disinkronkan dengan lampu latar - saat lampu latar dinyalakan, gambar ditampilkan, saat lampu latar dimatikan, gambar terhapus. Gambar dibentuk untuk tampilan UV - di sepanjang sisi pendek gambar. Saya tidak terburu-buru dengan pointer di sepanjang garis offset gambar ini, tetapi sebelum menampilkannya, saya memberikan pengontrol tampilan perintah untuk mengubah arah output untuk data yang sedang dituangkan, mis. area gambar ini ternyata "diputar" pada sisinya.



Di bawah ini adalah informasi tentang kemajuan pencetakan - waktu yang telah berlalu dan perkiraan waktu pencetakan, lapisan saat ini dan jumlah total lapisan, bilah kemajuan dengan persentase di sebelah kanannya. Saya juga ingin menambahkan tinggi saat ini dalam milimeter setelah jumlah lapisan, agar menjadi.



Di sebelah kanan adalah tombol jeda, pengaturan, dan interupsi. Saat Anda menekan jeda di firmware, bendera jeda disetel dan perilaku selanjutnya tergantung pada status printer saat ini. Jika platform turun untuk lapisan berikutnya atau eksposur lapisan sudah dimulai, firmware akan menyelesaikan eksposur dan hanya setelah itu akan menaikkan platform ke ketinggian jeda (yang diatur dalam pengaturan), di mana itu akan menunggu sampai pengguna mengklik tombol "Lanjutkan":







Menaikkan platform untuk jeda terjadi pertama kali pada kecepatan yang ditentukan dalam parameter file, dan setelah ketinggian yang ditentukan dalam parameter yang sama, kecepatan meningkat.



Ketika pencetakan terhenti, sebuah jendela akan muncul mengkonfirmasikan tindakan ini, dan hanya setelah konfirmasi, pencetakan akan dihentikan dan platform akan naik ke ketinggian sumbu maksimum. Kecepatan pengangkatan, serta selama jeda, bervariasi - pertama-tama secara perlahan untuk melepaskan lapisan dari film, dan kemudian meningkat hingga maksimum.



Tombol pengaturan belum berfungsi, tetapi ketika Anda mengkliknya, pengguna akan dibawa ke layar dengan parameter cetak yang dapat diubah - waktu eksposur lapisan, tinggi dan kecepatan angkat, dll. Sekarang saya sedang menyelesaikannya. Ada juga ide untuk memberikan kesempatan untuk menyimpan parameter yang diubah kembali ke file yang dicetak.



5. Setiap hal kecil, seperti mengontrol pencahayaan dan kipas, pengaturan pemuatan dan penyimpanan, dll.



Papan ini memiliki 3 keluaran MOSFET daya tinggi - satu untuk LED UV untuk penerangan dan dua untuk kipas (mendinginkan dioda iluminasi dan mendinginkan layar, misalnya). Tidak ada yang menarik di sini - output dari mikrokontroler terhubung ke gerbang transistor ini dan mengendalikannya semudah mem-flash LED. Untuk akurasi tinggi dari waktu eksposur, ini dinyalakan di siklus utama melalui fungsi pengaturan waktu operasi:



UVLED_TimerOn(l_info.light_time * 1000);

void		UVLED_TimerOn(uint32_t time)
{
	uvled_timer = time;
	UVLED_On();
}


Dan itu mati dari interupsi milidetik dari timer saat penghitung lampu latar mencapai nol:



...
	if (uvled_timer && uvled_timer != TIMER_DISABLE)
	{
		uvled_timer--;
		if (uvled_timer == 0)
			UVLED_Off();
	}
...


5.1 Pengaturan, memuat dari file dan menyimpan ke EEPROM



Pengaturan tersebut disimpan di EEPROM on-board di 24c16. Di sini, berbeda dengan menyimpan sumber daya dalam memori flash besar, semuanya sederhana - untuk setiap jenis data yang disimpan, offset alamat di dalam EEPROM dikodekan dengan keras. Secara total, tiga blok disimpan di dalamnya: pengaturan sumbu Z, pengaturan sistem umum (bahasa, suara, dll.) Dan penghitung waktu pengoperasian komponen utama printer - lampu, layar, dan kipas.



Struktur blok yang disimpan berisi versi firmware saat ini dan checksum primitif - hanya jumlah 16-bit dari nilai semua byte dalam blok. Saat membaca pengaturan dari EPROM, CRC diperiksa dan jika tidak sesuai dengan yang asli, maka parameter blok ini diberi nilai default, CRC baru dihitung dan blok disimpan di EPROM bukan yang lama. Jika blok baca tidak cocok dengan versi saat ini, maka itu harus diperbarui ke versi saat ini dan itu akan disimpan dalam bentuk baru, bukan yang lama. Ini belum diterapkan, tetapi akan dilakukan di masa mendatang untuk memperbarui firmware dengan benar.



Beberapa pengaturan dapat diubah melalui antarmuka, tetapi sebagian besar hanya dapat diubah dengan memuat file konfigurasi. Di sini saya tidak mengubah kebiasaan saya dan menulis parser saya sendiri untuk file semacam itu.



Struktur file semacam itu standar: nama parameter + tanda sama dengan + nilai parameter. Satu baris - satu parameter. Spasi dan tab di awal baris dan di antara tanda sama dengan dan nama serta nilai diabaikan. Baris kosong dan baris yang dimulai dengan karakter hash - "#" juga diabaikan, karakter ini mendefinisikan baris dengan komentar. Kasus huruf dalam nama parameter dan bagian tidak menjadi masalah.



Selain parameter, file tersebut juga berisi bagian yang namanya diapit oleh tanda kurung siku. Setelah nama bagian yang ditemukan, pengurai mengharapkan bahwa hanya parameter milik bagian ini yang akan melangkah lebih jauh hingga nama bagian lain ditemukan. Sejujurnya, saya tidak tahu mengapa saya memperkenalkan bagian ini. Ketika saya melakukan ini, saya memiliki semacam pemikiran yang terkait dengan mereka, tetapi sekarang saya tidak dapat mengingatnya.



Untuk mempersingkat perbandingan nama parameter baca dengan nama yang ditentukan sebelumnya, huruf pertama dari nama baca diuraikan terlebih dahulu, lalu hanya nama yang dimulai dengan huruf ini yang dibandingkan.



Isi file konfigurasi
# Stepper motor Z axis settings
[ZMotor]

	#    .
	#  : 0  1.  : 1.
	#         .
	invert_dir = 1

	#       .
	#  : -1  1.  : -1.
	#     -1,     
	#    ,   .   1
	#      .
	home_direction = -1

	#   Z    .  ,  
	#    0,   -   .
	home_pos = 0.0

	#         .
	#  :     -32000.0  32000.0.
	#  : -3.0
	#        . 
	#     ,    .
	min_pos = -3.0

	#         .
	#  :     -32000.0  32000.0.
	#  : 180.0
	#        . 
	#     ,    .
	max_pos = 180.0

	#   .
	#  : 0  1.  : 1.
	#         ,  
	#  1,   -  0.
	min_endstop_inverting = 1

	#   .
	#  : 0  1.  : 1.
	#         ,  
	#  1,   -  0.
	max_endstop_inverting = 1

	#     1   .
	steps_per_mm = 1600

	#  ,       
	# , /.  : 6.0.
	homing_feedrate_fast = 6.0

	#  ,       
	# , /.  : 1.0.
	homing_feedrate_slow = 1.0

	#     , /2.
	acceleration = 0.7

	#      , /.
	feedrate = 5.0

	#       (   ,
	#      ..), /2.
	travel_acceleration = 25.0

	#       (   ,
	#      ..), /.    30  
	#          ,   
	# 5 /.
	travel_feedrate = 25.0

	#       , .
	current_vref = 800.0

	#          , .
	current_hold_vref = 300.0

	#      ,    
	#    .   .  0  
	#    .
	hold_time = 30.0

	#      ,    
	# .   .       
	#   hold_time.  0   .
	#  ,       .
	off_time = 10.0



# General settings
[General]

	#      (0.001 )   
	#      .
	#  :  0  15000.  : 700 (0.7 ).
	buzzer_msg_duration = 700

	#      (0.001 )  
	#     ,   .
	#  :  0  15000.  : 70 (0.07 ).
	buzzer_touch_duration = 70

	#       180 .
	#            .
	#  : 0  1.  : 0.
	rotate_display = 0

	#           ,   .
	#    LCD-.      -   
	#  .
	#  :  0  15000.  : 10.  0   .
	screensaver_time = 10




Ketika file seperti itu (dengan ekstensi .acfg) dipilih dalam daftar file, firmware akan menanyakan apakah pengguna ingin mengunduh dan menerapkan pengaturan dari file ini dan, setelah konfirmasi, akan mulai mengurai file ini.







Jika kesalahan ditemukan, pesan akan ditampilkan yang menunjukkan jenis kesalahan dan nomor baris. Kesalahan berikut ditangani:



  • nama partisi tidak diketahui
  • nama parameter tidak diketahui
  • nilai parameter tidak valid - ketika, misalnya, parameter numerik dicoba untuk diberi nilai teks


Jika ada yang tertarik - berikut adalah lembar lengkap dari tiga fungsi utama parser
void			_cfg_GetParamName(char *src, char *dest, uint16_t maxlen)
{
	if (src == NULL || dest == NULL)
		return;
	
	char *string = src;
	// skip spaces
	while (*string != 0 && maxlen > 0 && (*string == ' ' || *string == '\t' || *string == '\r'))
	{
		string++;
		maxlen--;
	}
	// until first space symbol
	while (maxlen > 0 && *string != 0 && *string != ' ' && *string != '\t' && *string != '\r' && *string != '\n' && *string != '=')
	{
		*dest = *string;
		dest++;
		string++;
		maxlen--;
	}
	
	if (maxlen == 0)
		dest--;
	
	*dest = 0;
	return;
}
//==============================================================================




void			_cfg_GetParamValue(char *src, PARAM_VALUE *val)
{
	val->type = PARAMVAL_NONE;
	val->float_val = 0;
	val->int_val = 0;
	val->uint_val = 0;
	val->char_val = (char*)"";
	
	if (src == NULL)
		return;
	if (val == NULL)
		return;
	
	char *string = src;
	// search '='
	while (*string > 0 && *string != '=')
		string++;
	if (*string == 0)
		return;
	
	// skip '='
	string++;
	// skip spaces
	while (*string != 0 && (*string == ' ' || *string == '\t' || *string == '\r'))
		string++;
	if (*string == 0)
		return;

	// check param if it numeric
	if ((*string > 47 && *string < 58) || *string == '.' || (*string == '-' && (*(string+1) > 47 && *(string+1) < 58) || *(string+1) == '.'))
	{
		val->type = PARAMVAL_NUMERIC;
		val->float_val = (float)atof(string);
		val->int_val = atoi(string);
		val->uint_val = strtoul(string, NULL, 10);
	}
	else
	{
		val->type = PARAMVAL_STRING;
		val->char_val = string;
	}
	
	return;
}
//==============================================================================




void			CFG_LoadFromFile(void *par1, void *par2)
{
	sprintf(msg, LANG_GetString(LSTR_MSG_CFGFILE_LOADING), cfgCFileName);
	TGUI_MessageBoxWait(LANG_GetString(LSTR_WAIT), msg);

	UTF8ToUnicode_Str(cfgTFileName, cfgCFileName, sizeof(cfgTFileName)/2);
	if (f_open(&ufile, cfgTFileName, FA_OPEN_EXISTING | FA_READ) != FR_OK)
	{
		if (tguiActiveScreen == (TG_SCREEN*)&tguiMsgBox)
			tguiActiveScreen = (TG_SCREEN*)((TG_MSGBOX*)tguiActiveScreen)->prevscreen;
		TGUI_MessageBoxOk(LANG_GetString(LSTR_ERROR), LANG_GetString(LSTR_MSG_FILE_OPEN_ERROR));
		BUZZ_TimerOn(cfgConfig.buzzer_msg);
		return;
	}

	uint16_t		cnt = 0;
	uint32_t		readed = 0, totalreaded = 0;
	char			*string = msg;
	char			lexem[128];
	PARAM_VALUE		pval;
	CFGREAD_STATE	rdstate = CFGR_GENERAL;
	int16_t			numstr = 0;
	
	while (1)
	{
		// read one string
		cnt = 0;
		readed = 0;
		string = msg;
		while (cnt < sizeof(msg))
		{
			if (f_read(&ufile, string, 1, &readed) != FR_OK || readed == 0 || *string == '\n')
			{
				*string = 0;
				break;
			}
			cnt++;
			string++;
			totalreaded += readed;
		}
		if (cnt == sizeof(msg))
		{
			string--;
			*string = 0;
		}
		numstr++;
		string = msg;
		
		// trim spaces/tabs at begin and end
		strtrim(string);
		
		// if string is empty
		if (*string == 0)
		{
			// if end of file
			if (readed == 0)
				break;
			else
				continue;
		}
		
		// skip comments
		if (*string == '#')
			continue;
		
		// upper all letters
		strupper_utf(string);
		
		// get parameter name
		_cfg_GetParamName(string, lexem, sizeof(lexem));
		
		// check if here section name
		if (*lexem == '[')
		{
			if (strcmp(lexem, (char*)"[ZMOTOR]") == 0)
			{
				rdstate = CFGR_ZMOTOR;
				continue;
			}
			else if (strcmp(lexem, (char*)"[GENERAL]") == 0)
			{
				rdstate = CFGR_GENERAL;
				continue;
			}
			else
			{
				rdstate = CFGR_ERROR;
				string = LANG_GetString(LSTR_MSG_UNKNOWN_SECTNAME_IN_CFG);
				sprintf(msg, string, numstr);
				break;
			}
		}
		
		// get parameter value
		_cfg_GetParamValue(string, &pval);
		if (pval.type == PARAMVAL_NONE)
		{
			rdstate = CFGR_ERROR;
			string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);
			sprintf(msg, string, numstr);
			break;
		}
		
		// check and setup parameter
		switch (rdstate)
		{
			case CFGR_ZMOTOR:
				rdstate = CFGR_ERROR;
				if (*lexem == 'A')
				{
					if (strcmp(lexem, (char*)"ACCELERATION") == 0)
					{
						if (pval.type != PARAMVAL_NUMERIC)
						{
							string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);
							sprintf(msg, string, numstr);
							break;
						}
						if (pval.float_val < 0.1)
							pval.float_val = 0.1;
						cfgzMotor.acceleration = pval.float_val;
						rdstate = CFGR_ZMOTOR;
						break;
					}
				} else
				if (*lexem == 'C')
				{
					if (strcmp(lexem, (char*)"CURRENT_HOLD_VREF") == 0)
					{
						if (pval.type != PARAMVAL_NUMERIC)
						{
							string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);
							sprintf(msg, string, numstr);
							break;
						}
						if (pval.uint_val < 100)
							pval.uint_val = 100;
						if (pval.uint_val > 1000)
							pval.uint_val = 1000;
						cfgzMotor.current_hold_vref = pval.uint_val;
						rdstate = CFGR_ZMOTOR;
						break;
					}
					if (strcmp(lexem, (char*)"CURRENT_VREF") == 0)
					{
						if (pval.type != PARAMVAL_NUMERIC)
						{
							string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);
							sprintf(msg, string, numstr);
							break;
						}
						if (pval.uint_val < 100)
							pval.uint_val = 100;
						if (pval.uint_val > 1000)
							pval.uint_val = 1000;
						cfgzMotor.current_vref = pval.uint_val;
						rdstate = CFGR_ZMOTOR;
						break;
					}
				} else
				if (*lexem == 'F')
				{
					if (strcmp(lexem, (char*)"FEEDRATE") == 0)
					{
						if (pval.type != PARAMVAL_NUMERIC)
						{
							string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);
							sprintf(msg, string, numstr);
							break;
						}
						if (pval.float_val < 0.1)
							pval.float_val = 0.1;
						if (pval.float_val > 40)
							pval.float_val = 40;
						cfgzMotor.feedrate = pval.float_val;
						rdstate = CFGR_ZMOTOR;
						break;
					}
				} else
				if (*lexem == 'H')
				{
					if (strcmp(lexem, (char*)"HOLD_TIME") == 0)
					{
						if (pval.type != PARAMVAL_NUMERIC)
						{
							string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);
							sprintf(msg, string, numstr);
							break;
						}
						if (pval.uint_val == 0)
							pval.uint_val = TIMER_DISABLE;
						else if (pval.uint_val > 100000)
							pval.uint_val = 100000;
						cfgzMotor.hold_time = pval.uint_val * 1000;
						rdstate = CFGR_ZMOTOR;
						break;
					}
					if (strcmp(lexem, (char*)"HOME_DIRECTION") == 0)
					{
						if (pval.type != PARAMVAL_NUMERIC)
						{
							string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);
							sprintf(msg, string, numstr);
							break;
						}
						if (pval.int_val != -1.0 && pval.int_val != 1.0)
							pval.int_val = -1;
						cfgzMotor.home_dir = pval.int_val;
						rdstate = CFGR_ZMOTOR;
						break;
					}
					if (strcmp(lexem, (char*)"HOME_POS") == 0)
					{
						if (pval.type != PARAMVAL_NUMERIC)
						{
							string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);
							sprintf(msg, string, numstr);
							break;
						}
						cfgzMotor.home_pos = pval.float_val;
						rdstate = CFGR_ZMOTOR;
						break;
					}
					if (strcmp(lexem, (char*)"HOMING_FEEDRATE_FAST") == 0)
					{
						if (pval.type != PARAMVAL_NUMERIC)
						{
							string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);
							sprintf(msg, string, numstr);
							break;
						}
						if (pval.float_val < 0.1)
							pval.float_val = 0.1;
						if (pval.float_val > 40)
							pval.float_val = 40;
						cfgzMotor.homing_feedrate_fast = pval.float_val;
						rdstate = CFGR_ZMOTOR;
						break;
					}
					if (strcmp(lexem, (char*)"HOMING_FEEDRATE_SLOW") == 0)
					{
						if (pval.type != PARAMVAL_NUMERIC)
						{
							string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);
							sprintf(msg, string, numstr);
							break;
						}
						if (pval.float_val < 0.1)
							pval.float_val = 0.1;
						if (pval.float_val > 40)
							pval.float_val = 40;
						cfgzMotor.homing_feedrate_slow = pval.float_val;
						rdstate = CFGR_ZMOTOR;
						break;
					}
				} else
				if (*lexem == 'I')
				{
					if (strcmp(lexem, (char*)"INVERT_DIR") == 0)
					{
						if (pval.type != PARAMVAL_NUMERIC)
						{
							string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);
							sprintf(msg, string, numstr);
							break;
						}
						if (pval.int_val < 0 || pval.int_val > 1)
							pval.int_val = 1;
						cfgzMotor.invert_dir = pval.int_val;
						rdstate = CFGR_ZMOTOR;
						break;
					}
				} else
				if (*lexem == 'M')
				{
					if (strcmp(lexem, (char*)"MAX_ENDSTOP_INVERTING") == 0)
					{
						if (pval.type != PARAMVAL_NUMERIC)
						{
							string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);
							sprintf(msg, string, numstr);
							break;
						}
						if (pval.int_val < 0 || pval.int_val > 1)
							pval.int_val = 1;
						cfgzMotor.max_endstop_inverting = pval.int_val;
						rdstate = CFGR_ZMOTOR;
						break;
					}
					if (strcmp(lexem, (char*)"MAX_POS") == 0)
					{
						if (pval.type != PARAMVAL_NUMERIC)
						{
							string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);
							sprintf(msg, string, numstr);
							break;
						}
						cfgzMotor.max_pos = pval.float_val;
						rdstate = CFGR_ZMOTOR;
						break;
					}
					if (strcmp(lexem, (char*)"MIN_ENDSTOP_INVERTING") == 0)
					{
						if (pval.type != PARAMVAL_NUMERIC)
						{
							string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);
							sprintf(msg, string, numstr);
							break;
						}
						if (pval.int_val < 0 || pval.int_val > 1)
							pval.int_val = 1;
						cfgzMotor.min_endstop_inverting = pval.int_val;
						rdstate = CFGR_ZMOTOR;
						break;
					}
					if (strcmp(lexem, (char*)"MIN_POS") == 0)
					{
						if (pval.type != PARAMVAL_NUMERIC)
						{
							string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);
							sprintf(msg, string, numstr);
							break;
						}
						cfgzMotor.min_pos = pval.float_val;
						rdstate = CFGR_ZMOTOR;
						break;
					}
				} else
				if (*lexem == 'O')
				{
					if (strcmp(lexem, (char*)"OFF_TIME") == 0)
					{
						if (pval.type != PARAMVAL_NUMERIC)
						{
							string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);
							sprintf(msg, string, numstr);
							break;
						}
						if (pval.uint_val > 100000)
							pval.uint_val = 100000;
						else if (pval.uint_val < cfgzMotor.hold_time)
							pval.uint_val = cfgzMotor.hold_time + 1000;
						else if (pval.uint_val == 0)
							pval.uint_val = TIMER_DISABLE;
						cfgzMotor.off_time = pval.int_val * 60000;
						rdstate = CFGR_ZMOTOR;
						break;
					}
				} else
				if (*lexem == 'S')
				{
					if (strcmp(lexem, (char*)"STEPS_PER_MM") == 0)
					{
						if (pval.type != PARAMVAL_NUMERIC)
						{
							string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);
							sprintf(msg, string, numstr);
							break;
						}
						if (pval.uint_val < 1)
							pval.uint_val = 1;
						if (pval.uint_val > 200000)
							pval.uint_val = 200000;
						cfgzMotor.steps_per_mm = pval.uint_val;
						rdstate = CFGR_ZMOTOR;
						break;
					}
				} else
				if (*lexem == 'T')
				{
					if (strcmp(lexem, (char*)"TRAVEL_ACCELERATION") == 0)
					{
						if (pval.type != PARAMVAL_NUMERIC)
						{
							string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);
							sprintf(msg, string, numstr);
							break;
						}
						if (pval.float_val < 0.1)
							pval.float_val = 0.1;
						cfgzMotor.travel_acceleration = pval.float_val;
						rdstate = CFGR_ZMOTOR;
						break;
					}
					if (strcmp(lexem, (char*)"TRAVEL_FEEDRATE") == 0)
					{
						if (pval.type != PARAMVAL_NUMERIC)
						{
							string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);
							sprintf(msg, string, numstr);
							break;
						}
						if (pval.float_val < 0.1)
							pval.float_val = 0.1;
						cfgzMotor.travel_feedrate = pval.float_val;
						rdstate = CFGR_ZMOTOR;
						break;
					}
				}

				string = LANG_GetString(LSTR_MSG_UNKNOWN_PARAMNAME_IN_CFG);
				sprintf(msg, string, numstr);
				break;

			case CFGR_GENERAL:
				rdstate = CFGR_ERROR;
				if (*lexem == 'B')
				{
					if (strcmp(lexem, (char*)"BUZZER_MSG_DURATION") == 0)
					{
						if (pval.type != PARAMVAL_NUMERIC)
						{
							string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);
							sprintf(msg, string, numstr);
							break;
						}
						if (pval.uint_val > 15000)
							pval.uint_val = 15000;
						cfgConfig.buzzer_msg = pval.uint_val;
						rdstate = CFGR_GENERAL;
						break;
					}
					if (strcmp(lexem, (char*)"BUZZER_TOUCH_DURATION") == 0)
					{
						if (pval.type != PARAMVAL_NUMERIC)
						{
							string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);
							sprintf(msg, string, numstr);
							break;
						}
						if (pval.uint_val > 15000)
							pval.uint_val = 15000;
						cfgConfig.buzzer_touch = pval.uint_val;
						rdstate = CFGR_GENERAL;
						break;
					}
				} else
				if (*lexem == 'R')
				{
					if (strcmp(lexem, (char*)"ROTATE_DISPLAY") == 0)
					{
						if (pval.type != PARAMVAL_NUMERIC)
						{
							string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);
							sprintf(msg, string, numstr);
							break;
						}
						if (pval.uint_val > 0)
						{
							cfgConfig.display_rotate = 1;
							LCD_WriteCmd(0x0036);
							LCD_WriteRAM(0x0078);
						}
						else
						{
							cfgConfig.display_rotate = 0;
							LCD_WriteCmd(0x0036);
							LCD_WriteRAM(0x00B8);
						}
						rdstate = CFGR_GENERAL;
						break;
					}
				} else
				if (*lexem == 'S')
				{
					if (strcmp(lexem, (char*)"SCREENSAVER_TIME") == 0)
					{
						if (pval.type != PARAMVAL_NUMERIC)
						{
							string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);
							sprintf(msg, string, numstr);
							break;
						}
						if (pval.uint_val > 15000)
							cfgConfig.screensaver_time = 15000 * 60000;
						else if (pval.uint_val == 0)
							pval.uint_val = TIMER_DISABLE;
						else
							cfgConfig.screensaver_time = pval.uint_val * 60000;
						rdstate = CFGR_GENERAL;
						break;
					}
				}

				string = LANG_GetString(LSTR_MSG_UNKNOWN_PARAMNAME_IN_CFG);
				sprintf(msg, string, numstr);
				break;

		}
		
		if (rdstate == CFGR_ERROR)
			break;
		
		
	}
	f_close(&ufile);
	
	
	if (tguiActiveScreen == (TG_SCREEN*)&tguiMsgBox)
	{
		tguiActiveScreen = (TG_SCREEN*)((TG_MSGBOX*)tguiActiveScreen)->prevscreen;
	}

	if (rdstate == CFGR_ERROR)
	{
		TGUI_MessageBoxOk(LANG_GetString(LSTR_ERROR), msg);
		BUZZ_TimerOn(cfgConfig.buzzer_msg);
	}
	else
	{
		CFG_SaveMotor();
		CFG_SaveConfig();
		TGUI_MessageBoxOk(LANG_GetString(LSTR_COMPLETED), LANG_GetString(LSTR_MSG_CFGFILE_LOADED));
	}
}
//==============================================================================




Setelah parsing file berhasil, pengaturan baru segera diterapkan dan disimpan ke EPROM.



Penghitung jam operasi untuk komponen printer hanya diperbarui di EPROM ketika file dicetak atau disela.



6. Fitur tambahan untuk kenyamanan dan kemudahan



6.1 Jam dengan kalender



Nah, hanya untuk membuatnya. Mengapa membuang-buang kebaikan - jam waktu nyata otonom yang dibangun ke dalam mikrokontroler, yang dapat beroperasi pada baterai lithium dengan daya umum mati dan mengonsumsi sangat sedikit sehingga CR2032, menurut perhitungan, harus cukup untuk beberapa tahun. Selain itu, pabrikan bahkan menyediakan kuarsa 32 kHz yang diperlukan untuk jam tangan ini. Tetap hanya untuk merekatkan dudukan baterai ke papan dan menyolder kabel dari itu ke minus umum dan ke terminal khusus mikrokontroler, yang saya lakukan di rumah.



Waktu, hari dan bulan ditampilkan di kiri atas layar utama:







Jam waktu nyata yang sama digunakan untuk menghitung waktu pencetakan dan jam kerja komponen. Dan mereka juga digunakan dalam screensaver, yang dijelaskan di bawah ini.



6.2 Mengunci layar dari klik yang tidak disengaja selama pencetakan



Ini dilakukan atas permintaan seorang kenalan. Nah, mengapa tidak, ini bisa bermanfaat dalam beberapa kasus. Kunci dihidupkan dan dimatikan dengan menekan lama (~ 2,5 detik) pada kepala layar cetak. Saat kunci aktif, kunci merah ditampilkan di sudut kanan atas. Di akhir pencetakan, kunci dilepaskan secara otomatis.



6.3 Turunkan arus motor dalam mode tahan, matikan motor idle



Dibuat untuk mengurangi penumpukan panas secara keseluruhan di dalam bodi printer. Motor dapat disetel ke mode tahan dengan arus yang berkurang setelah waktu tanpa gerak yang dikonfigurasi. Fitur ini, bagaimanapun, tersebar luas di driver motor stepper "dewasa" dari tipe TB6560. Selain itu, dalam pengaturan, Anda dapat mengatur waktu setelah itu, jika tidak ada gerakan, motor akan benar-benar mati. Tetapi ini juga akan mengarah pada fakta bahwa pemfokusan sumbu, jika dilakukan, akan menjadi tidak valid. Kedua fitur ini dapat dinonaktifkan sepenuhnya dalam pengaturan yang sama.



6.4 Screensaver



Seperti jam tangan - hanya karena saya bisa. Jika tidak ada penekanan layar setelah waktu yang ditentukan dalam pengaturan, layar beralih ke mode emulasi jam desktop digital:







Selain waktu, tanggal lengkap dengan hari dalam seminggu juga ditampilkan. Firmware keluar dari mode ini dengan menekan di bagian manapun pada tampilan. Mengingat jumlahnya cukup besar, dan konsumsi listrik saat mesin mati kurang dari 2 watt, printer dengan screensaver seperti itu dapat berfungsi sebagai jam ruangan :) Selama pencetakan, screensaver juga muncul setelah waktu yang ditentukan, tetapi dengan satu tambahan - kemajuan pencetakan di bagian bawah layar:







Dalam pengaturan, Anda dapat mengatur waktu respons screensaver atau menonaktifkannya.



6.5 Pemeriksaan lampu latar dan tampilan







Layar ini dapat diakses dari menu "Layanan" dan berguna saat memeriksa dioda lampu latar atau tampilan UV. Di bagian atas, satu dari tiga gambar dipilih, yang akan ditampilkan pada layar UV - bingkai, iluminasi penuh dari seluruh tampilan, persegi panjang. Di bagian bawahnya terdapat dua buah tombol yang menghidupkan dan mematikan backlight dan display. Lampu yang disertakan akan mati secara otomatis setelah 2 menit, biasanya waktu ini cukup untuk pengujian apa pun. Saat Anda keluar dari layar ini, lampu latar dan tampilan akan mati secara otomatis.



6.6 Pengaturan







Layar ini juga dapat diakses dari menu Alat. Ada sangat sedikit pengaturan di sini dan, sejujurnya, saya tidak pernah menemukan pengaturan mana yang akan begitu sering dibutuhkan sehingga masuk akal untuk meletakkannya di antarmuka, dan tidak hanya di file konfigurasi. Ini juga akan menambah kemampuan untuk mengatur ulang penghitung waktu operasi untuk komponen printer, yah, saya tidak tahu lagi :)



Tentu saja, di sini Anda dapat mengatur waktu dan tanggal (karena ada jam) di layar yang terbuka secara terpisah:







Anda dapat mengatur ketinggian angkat platform saat jeda dan menyalakan dan mematikannya suara klik layar dan pesan. Saat mengubah pengaturan, nilai baru hanya akan berlaku sampai daya dimatikan dan tidak akan disimpan ke EPROM. Untuk menyimpannya, setelah mengubah parameter, tekan tombol simpan di menu (dengan ikon floppy).



Nilai numerik dimasukkan di layar khusus:







Di sini saya telah menerapkan semua fitur yang saya kurang di printer lain.



  1. Tombol "±" dan "." berfungsi hanya jika parameter yang diedit masing-masing bisa negatif atau pecahan.
  2. Jika, setelah memasuki layar ini, tombol angka apa pun ditekan terlebih dahulu, nilai lama akan diganti dengan digit yang sesuai. Jika tombolnya adalah ".", Ini akan diganti dengan "0". Artinya, tidak perlu menghapus nilai lama, Anda dapat segera mulai memasukkan nilai baru.
  3. Tombol "", memusatkan perhatian pada nilai saat ini.



    Menekan tombol Kembali tidak akan menerapkan nilai baru. Untuk menerapkannya, Anda perlu mengklik "OK".


6.7 Akhirnya - Layar Informasi Printer







Layar ini dapat diakses langsung dari menu utama. Yang paling penting di sini adalah versi firmware / FPGA dan penghitung waktu pengoperasian. Di bagian bawah, masih ada informasi tentang pembuat antarmuka dan alamat repositori di GitHub. Penulis antarmuka adalah fondasi untuk masa depan. Jika saya masih memungkinkan untuk mengkonfigurasi antarmuka melalui file teks sederhana, maka akan ada kesempatan untuk menentukan nama pembuatnya.



tamat



Ini adalah bagian terakhir dari proyek kesayangan saya ini. Proyek hidup dan berkembang, meskipun tidak secepat yang saya inginkan, tetapi sudah cukup efisien.



Saya mungkin harus memasukkan lebih banyak kode ... Tapi saya rasa tidak ada potongan dalam kode saya untuk dibanggakan. Menurut saya, yang lebih penting adalah mendeskripsikan cara kerjanya dan apa yang telah dilakukan, dan kodenya ada, semuanya ada di GitHub, siapa yang akan tertarik, saya bisa menontonnya di sana secara keseluruhan. Aku pikir begitu.



Saya menantikan pertanyaan dan komentar Anda, dan terima kasih atas minat Anda pada artikel ini.



- Bagian 1: 1. Antarmuka pengguna.

- Bagian 2: 2. Bekerja dengan sistem file pada USB flash drive. 3. Kontrol motor stepper untuk gerakan platform.

- Bagian 3:4. Output gambar dari lapisan ke tampilan lampu latar. 5. Setiap hal kecil, seperti mengontrol pencahayaan dan kipas, pengaturan pemuatan dan penyimpanan, dll. 6. Fitur tambahan untuk kenyamanan dan kemudahan.



Tautan



Kit MKS DLP di Aliexpress

Sumber firmware asli dari pabrikan di

Skema GitHub dari pabrikan dua versi papan di GitHub

Sumber saya di GitHub



All Articles