شِفر (کود) جستجو

Makerfabs MaTouch ESP32-S3 ۲.۸ اینچی دوربین ESP32-S3 هوش مصنوعی بینایی: دوربین را نشانه بگیرید و بپرسید چه میبیند

Makerfabs MaTouch ESP32-S3 ۲.۸ اینچی دوربین ESP32-S3 هوش مصنوعی بینایی: دوربین را نشانه بگیرید و بپرسید چه میبیند

برد تصویری می‌گیرد، یک هوش مصنوعی آنچه را می‌بیند توصیف می‌کند، و پاسخ را با صدای بلند می‌گوید

دوربین را به سمت چیزی بگیرید، دکمه‌ای را لمس کنید، و یک هوش مصنوعی به شما می‌گوید که به چه چیزی نگاه می‌کند - روی صفحه چاپ می‌شود و از طریق بلندگو پخش می‌شود. دو حالت: نام‌گذاری اشیاء در دید، یا توصیف شخص مقابل دوربین.

AI Vision - Point It At Something running on the MaTouch AI ESP32-S3 board AI Vision - Point It At Something در حال اجرا روی برد MaTouch AI ESP32-S3

چگونه تصویر به آنجا می‌رسد

دوربین یک JPEG با ابعاد ۸۰۰×۶۰۰ حدود ۲۰-۴۰ کیلوبایت تولید می‌کند. برد آن را در PSRAM به صورت base64 رمزگذاری می‌کند، که آن را تقریباً یک سوم بزرگ‌تر به عنوان متن می‌کند، و آن را در درخواست به عنوان یک data URI جاسازی می‌کند. ۸ مگابایت PSRAM چیزی است که این کار را راحت می‌کند.

توصیف، هرگز شناسایی

حالت PERSON آنچه را که می‌تواند ببیند توصیف می‌کند - خلق‌وخو، عینک، آنچه به نظر می‌رسد شخص انجام می‌دهد. به شما نمی‌گوید که شخص کیست، و این عمدی است. شناسایی افراد از روی چهره برخلاف سیاست‌های استفاده OpenAI است، و سرویس معادل شناسایی چهره مایکروسافت پشت فرآیند تأییدی قفل شده است که توسعه‌دهندگان عادی نمی‌توانند به سادگی برای آن ثبت‌نام کنند. هر آموزشی که به شما شناسایی چهره ابری را وعده دهد، چیزی را توصیف می‌کند که به طور کلی در دسترس نیست.

اگر بردی می‌خواهید که افراد خاص را تشخیص دهد، از پروژه 03b استفاده کنید - آن را آفلاین انجام می‌دهد و هرگز چهره کسی را به جایی ارسال نمی‌کند.

دوربین باید برای شبکه متوقف شود

پیش‌نمایش زنده در حالی که سؤال پرسیده می‌شود خاموش می‌شود. این ظاهری نیست: درایور دوربین در حال اجرا حافظه داخلی را که اتصال امن نیاز دارد نگه می‌دارد، و با اجرای پیش‌نمایش اتصال به سادگی شکست می‌خورد. خاموش کردن دوربین آن را آزاد می‌کند. همچنین به این معنی است که پاسخ قابل خواندن است به جای اینکه بیش از ۲۵ بار در ثانیه بازنویسی شود.

پاسخ تا زمانی که آن را رد کنید باقی می‌ماند

پاسخ روی صفحه باقی می‌ماند تا زمانی که CLEAR را لمس کنید - هیچ تایمری وجود ندارد. در حالی که نمایش داده می‌شود، دکمه REPLAY پاسخ گفتاری را دوباره از حافظه پخش می‌کند، بدون تماس دوم با API و بدون هزینه اضافی.

درباره برد MaTouch AI ESP32-S3 2.8"

هر پروژه در این صفحه روی MaTouch AI ESP32-S3 2.8" TFT ST7789V از Makerfabs اجرا می‌شود. این یک برد همه‌کاره است: یک صفحه لمسی رنگی، یک دوربین ۳ مگاپیکسلی، دو میکروفون و یک تقویت‌کننده بلندگوی واقعی، همه توسط یک ESP32-S3 با ۸ مگابایت PSRAM هدایت می‌شوند. این ترکیب چیزی است که این پروژه‌های هوش مصنوعی را روی یک برد واحد بدون هیچ چیز متصل دیگری ممکن می‌سازد.

۸ مگابایت PSRAM بیشتر از هر عدد دیگری در اینجا اهمیت دارد. این چیزی است که به برد اجازه می‌دهد یک فریم دوربین، چند ثانیه صدای ضبط‌شده، یا یک عکس رمزگذاری‌شده base64 را همزمان در حافظه نگه دارد - هیچ‌کدام در RAM عادی ESP32 جا نمی‌شوند.

مستندات سازنده: صفحه ویکی Makerfabs.

مشخصات کلیدی

  • پردازنده: ESP32-S3، دو هسته‌ای ۲۴۰ مگاهرتز، WiFi 2.4 گیگاهرتز + بلوتوث 5.0

  • حافظه: ۱۶ مگابایت فلش، ۸ مگابایت PSRAM (تقریباً برای هر پروژه اینجا الزامی است)

  • نمایشگر: 2.8 اینچ IPS، ۳۲۰×۲۴۰، درایور ST7789V، SPI

  • لمس: GT911 خازنی، ردیابی ۵ انگشت همزمان

  • دوربین: OV3660، ۳ مگاپیکسل، تا ۲۰۴۸×۱۵۳۶

  • میکروفون‌ها: دو میکروفون دیجیتال I2S INMP441 (یک جفت استریوی واقعی)

  • بلندگو: تقویت‌کننده کلاس D MAX98357A، 3.2 وات در ۴ اهم

  • ذخیره‌سازی: اسلات کارت microSD (حالت SPI)

  • برق: USB-C، کانکتور باتری JST، شارژر TP4056، کلید برق

  • همچنین روی برد: LED RGB WS2812B، ساعت واقعی PCF8563T با باتری پشتیبان، و یک نشانگر سطح باتری MAX17048 که در مشخصات رسمی لیست نشده است

دو پورت USB-C یکسان نیستند. بلندگوی برد پایه‌های سیگنال خود (IO19 و IO20) را با پورت USB بومی به اشتراک می‌گذارد، زیرا آن پایه‌ها خطوط داده USB سخت‌افزاری ESP32-S3 هستند. همیشه از طریق پورت USB-C CH340K (پورت کنار دکمه RESET) آپلود و برق دهید، و USB CDC On Boot را روی Disabled تنظیم کنید. از پورت اشتباه استفاده کنید و صدا بد رفتار می‌کند یا آپلودها شکست می‌خورند.

تنظیمات Arduino IDE

این تنظیمات مهم هستند. بیشتر مشکلاتی که مردم با این برد گزارش می‌دهند یکی از این موارد اشتباه است، و آنها وقتی نسخه هسته را تغییر می‌دهید بازنشانی می‌شوند، بنابراین پس از هر تغییری دوباره آنها را بررسی کنید.

تنظیم

مقدار

برد

ESP32S3 Dev Module

نسخه هسته ESP32

2.0.17

PSRAM

OPI PSRAM

اندازه فلش

16MB (128Mb)

طرح پارتیشن

16M Flash (3MB APP/9.9MB FATFS)

USB CDC On Boot

Disabled

سرعت آپلود

921600

پاک کردن همه فلش قبل از آپلود

Disabled

پورت

پورت USB-C CH340K

از هسته ESP32 نسخه 2.0.17 استفاده کنید، نه 3.x. Espressif مدل‌های تشخیص چهره روی دستگاه را در هسته 3 حذف کرده است، بنابراین پروژه‌های چهره آنجا کامپایل نمی‌شوند. ثابت نگه داشتن 2.0.17 باعث می‌شود هر پروژه در این صفحه با یک پیکربندی کار کند. در Boards Manager، منوی کشویی نسخه به شما اجازه می‌دهد هر زمان که خواستید به جلو و عقب تغییر دهید.

برای آردوینو از نسخه‌ی 1.5.6 کتابخانه‌ی GFX Library استفاده کنید، نه 1.6.x. نسخه‌های 1.6 برای هسته‌ی ESP32 نسخه‌ی 3 ساخته شده‌اند و ممکن است هنگام راه‌اندازی روی هسته‌ی 2.0.17 هنگ کنند. اگر بعد از آپلود صفحه‌ی شما سیاه ماند، اولین چیزی که باید بررسی کنید این است.

کتابخانه‌های مورد نیاز

این‌ها را از طریق Tools → Manage Libraries در Arduino IDE نصب کنید. شماره‌ی نسخه‌ها مهم است - لطفاً از نسخه‌های ذکر شده استفاده کنید.

کتابخانه

نسخه

نویسنده

GFX Library for Arduino

1.5.6

moononournation

bb_captouch

1.3.1

Larry Bank

ArduinoJson

7.x

Benoit Blanchon

Adafruit NeoPixel

هر نسخه‌ی جدید

Adafruit

تنظیم فایل secrets.h

جزئیات WiFi و هر کلید API شما در secrets.h قرار می‌گیرد که با مقادیر نمونه در دانلود گنجانده شده است. آن تب را در Arduino IDE باز کنید و مقادیر را با مقادیر خودتان جایگزین کنید.

WiFi باید 2.4 گیگاهرتز باشد. ESP32-S3 به هیچ وجه نمی‌تواند شبکه‌ی 5 گیگاهرتز را ببیند. اگر روتر شما هر دو باند را با یک نام ترکیب می‌کند (ایسوس این را Smart Connect می‌نامد)، یا آن را خاموش کنید یا به باند 2.4 گیگاهرتز نام جداگانه‌ای بدهید و از آن در secrets.h استفاده کنید.

دریافت کلیدهای API

این پروژه با یک سرویس هوش مصنوعی ابری ارتباط برقرار می‌کند، بنابراین به کلید اختصاصی خودتان نیاز دارید. اگر قبلاً این کار را نکرده‌اید، نگران نباشید - این همان ایده‌ی رمز عبوری است که حساب شما را به سرویس معرفی می‌کند. فقط چند دقیقه طول می‌کشد، یک بار.

یک کلید، اشتراک یک وب‌سایت نیست. برای مثال، پرداخت برای ChatGPT Plus به شما کلید API نمی‌دهد - این دو محصول جداگانه با صورتحساب جداگانه هستند. شما به یک حساب در پلتفرم توسعه‌دهنده نیاز دارید، همان‌طور که در زیر توضیح داده شده است.

OpenAI - چشم‌ها

OpenAI مدل بینایی را فراهم می‌کند که به تصاویر نگاه می‌کند. فقط پروژه‌هایی که تصویر ارسال می‌کنند به این نیاز دارند.

  1. به platform.openai.com/api-keys بروید و وارد شوید یا حساب بسازید.

  2. روی Create new secret key کلیک کنید، یک نام به آن بدهید و آن را ایجاد کنید.

  3. فوراً آن را کپی کنید - مانند DeepSeek، فقط یک بار نمایش داده می‌شود.

  4. Billing را باز کنید و مقدار کمی اعتبار اضافه کنید. API پیش‌پرداخت است و از هر اشتراک ChatGPT که ممکن است داشته باشید جدا است.

کلید را به عنوان OPENAI_KEY در secrets.h قرار دهید.

Microsoft Azure Speech - برای شنیدن و صحبت کردن

Azure گفتار شما را به متن تبدیل می‌کند و پاسخ را دوباره به صدا تبدیل می‌کند. سطح رایگان برای همه‌ی چیزهای این صفحه به اندازه‌ی کافی سخاوتمندانه است.

  1. به portal.azure.com بروید و با یک حساب مایکروسافت وارد شوید (حساب رایگان کافی است).

  2. اگر قبلاً از Azure استفاده نکرده‌اید، صفحه‌ی Welcome to Azure با سه گزینه می‌بینید. Start with an Azure free trial را انتخاب کنید - قبل از اینکه Azure به شما اجازه دهد چیزی بسازید به یک اشتراک نیاز دارید. (دانشجویان باید به جای آن Azure for Students را انتخاب کنند: نتیجه یکسان است، کارت لازم نیست.) Manage Microsoft Entra ID را نادیده بگیرید که چیز کاملاً متفاوتی است.

  3. روی Create a resource کلیک کنید، Speech را جستجو کنید و Speech service منتشر شده توسط مایکروسافت را انتخاب کنید.

  4. فرم را پر کنید: هر گروه منبع، هر نام، و یک Region نزدیک خود انتخاب کنید - آن منطقه را دقیقاً همان‌طور که ظاهر می‌شود یادداشت کنید، مثلاً eastus.

  5. برای Pricing tier گزینه‌ی F0 (Free) را انتخاب کنید. این حدود پنج ساعت تبدیل گفتار به متن و پانصد هزار خصیصه تبدیل متن به گفتار در هر ماه اجازه می‌دهد.

  6. روی Review + create و سپس Create کلیک کنید. حدود یک دقیقه صبر کنید، سپس روی Go to resource کلیک کنید.

  7. در منوی سمت چپ Keys and Endpoint را باز کنید. KEY 1 و Location/Region را کپی کنید.

آن‌ها را به عنوان AZURE_SPEECH_KEY و AZURE_REGION در secrets.h قرار دهید. برای AZURE_STT_HOST، از <region>.stt.speech.microsoft.com استفاده کنید - بنابراین با منطقه‌ی eastus این می‌شود eastus.stt.speech.microsoft.com.

درباره‌ی کارت اعتباری. آزمایش رایگان Azure برای تأیید هویت شما کارت می‌خواهد. از شما هزینه‌ای دریافت نمی‌کند. شما 200 دلار اعتبار برای 30 روز دریافت می‌کنید و بعد از آن حساب به Pay-As-You-Go منتقل می‌شود - اما سطح F0 Speech رایگان می‌ماند، ماه به ماه، و همه‌ی چیزهای این پروژه‌ها به راحتی در آن جا می‌شود. اگر ترجیح می‌دهید اصلاً کارت ندهید و دانشجو هستید، گزینه‌ی Azure for Students بدون کارت به شما اعتبار می‌دهد.

باید منبع «Speech service» باشد. کلید یک منبع Translator، Language یا Cognitive Services عمومی یکسان به نظر می‌رسد و کاملاً معتبر است - اما هر درخواست گفتار خطای 401 برمی‌گرداند. این در طول آزمایش ما را گرفت و یک ساعت هزینه داشت. اگر گفتار با 401 شکست خورد در حالی که کلید درست به نظر می‌رسد، بررسی کنید چه نوع منبعی ساخته‌اید.

هزینه‌ی اجرا چقدر است

خیلی کم است، اما رایگان نیست و باید قبل از اینکه پروژه‌ای را در حال اجرا بگذارید تقریباً بدانید چقدر خرج می‌کنید.

خدمت

هزینه تقریبی

Azure Speech

لایه رایگان حدود ۵ ساعت گوش دادن و ۰.۵ میلیون خصیصه صحبت در ماه را پوشش می‌دهد

DeepSeek

کسری از یک سنت برای هر پاسخ - هزاران پاسخ با چند دلار

OpenAI vision

تقریباً یک یا دو سنت برای هر تصویر، بسته به مدل

قیمت‌ها تغییر می‌کنند، بنابراین این‌ها را به‌عنوان راهنما در نظر بگیرید نه قیمت قطعی. هر یک از این خدمات صفحه‌ای برای مصرف دارد که می‌توانید هزینه‌های خود را تماشا کنید، و همه آن‌ها به شما اجازه می‌دهند سقف هزینه تعیین کنید - که ارزش انجام دادن از روز اول را دارد.

کلیدهای خود را خصوصی نگه دارید. هر کسی که آن‌ها را داشته باشد می‌تواند پول شما را خرج کند. آن‌ها را در ویدیو، اسکرین‌شات، پست انجمن یا مخزن شِفر (کود) عمومی قرار ندهید. اگر کلیدی فاش شد، آن را در وب‌سایت ارائه‌دهنده حذف کنید و یک کلید جدید بسازید - این کار چند ثانیه طول می‌کشد و تنها راه‌حل واقعی است.

عیب‌یابی

علامت

علت و راه‌حل

صفحه سیاه می‌ماند

نسخه اشتباه کتابخانه GFX (از ۱.۵.۶ استفاده کنید) یا تنظیمات برد اشتباه.

PSRAM alloc failed یا خطای دوربین 0xffffffff

Tools → PSRAM روی OPI PSRAM تنظیم نشده است.

هیچ چیزی آپلود نمی‌شود / پورت COM وجود ندارد

پورت USB-C اشتباه است، یا درایور CH340 نصب نشده است.

دوربین از کار می‌افتد و هرگز بازیابی نمی‌شود

خط تنظیم مجدد دوربین به دکمه RESET برد متصل است، بنابراین نرم‌افزار نمی‌تواند آن را راه‌اندازی مجدد کند. RESET را فشار دهید. اگر همچنان از کار می‌افتد، کابل نواری دوربین را دوباره جا بزنید.

دانلود شِفر (کود)

اسکچ کامل آردوینو برای این پروژه، همراه با pins.h و هر چیز دیگری که نیاز دارد، به‌صورت رایگان قابل دانلود است.

دانلود 05_Vision_AI

آن را از حالت فشرده خارج کنید، فایل .ino را در IDE آردوینو باز کنید، تنظیمات بالا را بررسی کنید، و از طریق پورت CH340K USB-C آپلود کنید.

885-Arduin code for MaTouch AI ESP32S3 2.8in AI Camera: AI Vision
زبان: C++
/*
 * ===========================================================================
 * 05_ویژن_هوش مصنوعی - MaTouch AI ESP32-S3 2.8" TFT ST7789V
 * ===========================================================================
 * 
 * ---------------------------------------------------------------------------
 * ویدئو و دستورالعمل‌های مکتوب
 * 
 * ویدئو را تماشا کنید: https://youtu.be/Px3FT47RV2M
 * مقاله کامل: https://robojax.com/RTJ852
 * ("ویژن هوش مصنوعی" - سیم‌کشی، تنظیمات، اسکرین‌شات‌ها 
 * و پاسخ به مشکلات رایج)
 * 
 * تماشای ویدئو ابتدا وقت شما را صرفه‌جویی می‌کند - این ویدئو 
 * تنظیمات IDE آردوینو و نسخه‌های کتابخانه را مرحله به مرحله 
 * نشان می‌دهد.
 * ---------------------------------------------------------------------------
 * 
 * این برد می‌بیند. دوربین را به سمت چیزی بگیرید، یک دکمه را فشار 
 * دهید و یک مدل بینایی OpenAI به شما می‌گوید که به چه چیزی 
 * نگاه می‌کند - روی صفحه نمایش داده می‌شود و (اختیاری) از 
 * بلندگوی خود برد از طریق Azure TTS بیان می‌شود.
 * 
 * دو حالت، دو دکمه:
 * 
 * اشیاء "چه چیزی می‌بینی؟" - نام اشیاء مقابل لنز را می‌گوید.
 * شخص توصیف فرد در قاب: عینک، بیان صورت، آن‌ها چه کار 
 * می‌کنند. فقط توصیف - نباید و نمی‌تواند بگوید چه کسی 
 * هستند. شناسایی افراد از روی چهره مغایر با سیاست‌های 
 * استفاده OpenAI است و این تصمیم درستی است:
 * این را در ویدئو بگویید، ارزش 15 ثانیه صادقانه را دارد.
 * 
 * چرا OpenAI برای این دمو و نه DeepSeek: API DeepSeek فقط متن 
 * پذیرفته می‌شود. این نمی‌تواند هیچ عکسی را قبول کند. Azure 
 * OpenAI می‌تواند این کار را انجام دهد، اما OpenAI عادی یک 
 * نقطه پایانی است که هیچ تنظیم پیاده‌سازی ندارد - ساده‌تر 
 * است که به آن عمل کنید.
 * 
 * چگونه تصویر منتقل می‌شود: OV3660 به ما یک JPEG مستقیماً 
 * می‌دهد (800x600، ~40 کیلوبایت). ما آن را در PSRAM 
 * (base64) رمزگذاری می‌کنیم (~55 کیلوبایت متن) و در 
 * درخواست JSON به عنوان یک داده: URI جاسازی می‌کنیم. PSRAM 
 * 8MB این کار را بی‌دردسر می‌کند.
 * 
 * ---------------------------------------------------------------------------
 * قبل از فلش‌کردن secrets.h را پر کنید
 * (نیاز به WIFI_*, OPENAI_*; AZURE_* فقط اگر SPEAK_REPLIES 
 * 1 باشد).
 * 
 * تنظیمات برد (منوی ابزار - هر خط مهم است، اشتباه = صفحه 
 * سیاه یا خطاهای کامپایل. این‌ها هنگام تغییر هسته‌ها 
 * تنظیم مجدد می‌شوند - دوباره بررسی کنید!)
 * 
 * برد: ماجیول توسعه ESP32S3
 * هسته ESP32: 2.0.17
 * PSRAM: OPI PSRAM <-- الزامی، بافرهای تصویر در آنجا زندگی 
 * می‌کنند
 * حجم فلش: 16MB (128Mb)
 * طرح بندی پارتیشن: 16M Flash (3MB APP/9.9MB FATFS)
 * USB CDC در بوت: غیرفعال <-- بلندگو پایه‌ها را با USB 
 * ناتیو به اشتراک می‌گذارد
 * سرعت بارگذاری: 921600
 * پورت: پورت USB-C CH340K (پورت نزدیک به RESET)
 * 
 * کتابخانه‌ها
 * کتابخانه GFX برای آردوینو v1.5.6 (نه 1.6.x - این با 
 * هسته 3 جفت می‌شود)
 * bb_captouch v1.3.1
 * ArduinoJson v7.x
 * Adafruit NeoPixel هر نسخه اخیر
 * 
 * ---------------------------------------------------------------------------
 * توابع در این اسکریپت
 * led(r,g,b) رنگ LED وضعیت RGB را تنظیم می‌کند
 * getTouch(&x,&y) پنل لمسی را می‌خواند، به مختصات صفحه 
 * نقشه‌برداری می‌شود
 * camStart(...) دوربین را در فرمت/اندازه معین آغاز می‌کند
 * camStartPreview() دوربین RGB565 زنده 240x240 را آغاز می‌کند
 * captureJpeg(&len) یک JPEG 800x600 را به PSRAM می‌گیرد 
 * (دوربین بعد از آن خاموش می‌ماند - caller پیش‌نمایش را 
 * دوباره آغاز می‌کند)
 * readHttpResponse() پاسخ HTTPS را خوانده و به درستی 
 * خارج از بسته می‌کند
 * askVision(...) عکس + درخواست را به OpenAI ارسال کرده، 
 * پاسخ را برمی‌گرداند
 * spkInit() خروجی بلندگوی I2S را پیکربندی می‌کند
 * readExact(...) دقیقاً N بایت از یک اتصال TLS می‌خواند
 * speak(text) Azure TTS -> دانلود صدا به PSRAM -> پخش 
 * آن
 * playLastAnswer() پخش صدا ذخیره‌شده از PSRAM (دکمه 
 * پخش مجدد)
 * drawButton(...) یک دکمه در ستون جانبی ترسیم می‌کند
 * drawWifi() نوار سیگنال WiFi + خوانش dBm
 * drawButtons() ستون جانبی عادی (دو دکمه پرسش)
 * drawClearSide() ستون جانبی حالت پاسخ (CLEAR + REPLAY)
 * showAnswer(text) لایه‌بندی پاسخ با کلمات در ویوفایندر
 * lookAndTell(...) یک چرخه کامل: ضبط -> پرسش -> نمایش -> 
 * بیان
 * setup() / loop() توالی بوت / مشاهده کننده + مدیریت 
 * لمس
 * 
 * Robojax.com
 * ===========================================================================
 */
#define SPEAK_REPLIES  1 // ۱ = پاسخ را با Azure TTS به صورت بلند بخوانید، ۰ = فقط صفحه

/*
 * روشنایی LED وضعیت، ۰-۲۵۵. WS2812 از منبع تغذیه کار می‌کند و در حداکثر توان بسیار روشن است - ۲۵ در دوربین به راحتی قابل مشاهده است.
 */
#define LED_BRIGHTNESS 15

/*
 * چرخش دوربین
 * 1 = دوربین به همان سمتی که صفحه نمایش قرار دارد، نگاه می‌کند (چگونه برد ارسال می‌شود) - تصویر معکوس است تا زمانی که آن را به سمت خودتان نگه دارید، طبیعی به نظر برسد.
 * 0 = شما روبان را تا کرده‌اید تا لنز به سمت برعکس صفحه نمایش قرار گیرد (به سبک گوشی، صفحه به سمت شما / دوربین به سمت سوژه).
 * اگر تصویر به صورت چپ و راست معکوس به نظر می‌رسد، این عدد را معکوس کنید.
 */
#define CAMERA_FACES_USER  1

#include <Arduino_GFX_Library.h>
#include <bb_captouch.h>
#include <Adafruit_NeoPixel.h>
#include <ArduinoJson.h>
#include <Wire.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include "mbedtls/base64.h"
#include "esp_camera.h"
#include "pins.h"
#include "secrets.h"

#if SPEAK_REPLIES
#include "driver/i2s.h"
#define WAV_HEADER_LEN 44
#endif

Arduino_ESP32SPI *bus = new Arduino_ESP32SPI(
    TFT_DC, TFT_CS, TFT_SCLK, TFT_MOSI, TFT_MISO, HSPI, true);
Arduino_GFX *gfx = new Arduino_ST7789(bus, TFT_RES, 1, true);

BBCapTouch bbct;
Adafruit_NeoPixel rgb(RGB_LED_NUM, RGB_LED_PIN, NEO_GRB + NEO_KHZ800);

/*
 * --- دو دستور视觉 ------------------------------------------------
 * پاسخ‌ها را کوتاه نگه‌دارید: آن‌ها باید در صفحه نمایش 320x240 جا بگیرند و اگر بیان شوند، نباید مجری را در دوربین به حالت عجیب و غریب معطل کنند.
 */
const char *PROMPT_OBJECTS =
    "Look at this photo from a small camera. In ONE short sentence of at most "
    "20 words, plain text only, name the main object(s) you see.";

const char *PROMPT_PERSON =
    "Look at this photo. If there is a person, describe them in ONE short "
    "sentence of at most 20 words: mood, glasses or not, what they are doing. "
    "Never guess who they are. If no person is visible, say so. Plain text only.";

/*
 * --- چیدمان: نمایاب چپ، دکمه‌ها راست ------------------------------- */
 * #define BTN_X 242
 * #define BTN_W 78
 * #define BTN_H 52
 * #define BTN_OBJ_Y 4
 * #define BTN_PERSON_Y 62
 * 
 * void led(uint8_t r, uint8_t g, uint8_t b) { rgb.setPixelColor(0, rgb.Color(r, g, b)); rgb.show(); }
 * 
 * /* ===========================================================================
 *  لمس
 * ===========================================================================
 */
bool getTouch(uint16_t *x, uint16_t *y) {
  TOUCHINFO ti;
  if (!bbct.getSamples(&ti)) return false;
  if (ti.count < 1) return false;
  *x = ti.y[0];
  *y = (ti.x[0] > 240) ? 0 : (240 - ti.x[0]);
  return true;
}


/*
 * دوربین - RGB565 برای نمای زنده؛ ضبط JPEG با راه‌اندازی مجدد درایور انجام می‌شود، همان تکنیک مانند طرح 02.
 */
bool mirrored = CAMERA_FACES_USER; // به تعریف در بالای فایل مراجعه کنید

/*
 * چشمViewfinder در حین برقراری تماس ابری و در حین نمایش پاسخ روی صفحه خاموش است. دو دلیل، هر دو در آزمایشگاه یاد گرفته شده‌اند: 
 * 1. RAM: درایور دوربین زنده حافظه داخلی را که برای handshakeTLS نیاز است می‌خورد - با اجرای پیش‌نمایش، اتصال به OpenAI شکست می‌خورد (HTTP -1). 
 * 2. خوانایی: چشمViewfinder ۲۵ بار در ثانیه نقاشی می‌شود و پاسخ را پاک می‌کند. 
 * پاسخ تا زمانی که کاربر بر روی CLEAR ضربه نزند، روی صفحه باقی می‌ماند - بدون زمان‌سنج.
 */
bool preview_on = false;

/*
 * پاسخ داده شده آخرین در PSRAM نگه‌داری می‌شود تا دکمه REPLAY بتواند بدون تماس دیگر با ابر آن را دوباره پخش کند. با پاسخ بعدی بازنویسی می‌شود.
 */
uint8_t *last_audio = nullptr;
size_t   last_audio_len = 0;

bool camStart(pixformat_t fmt, framesize_t size, int fb_count, int quality) {
  camera_config_t c;
  c.ledc_channel = LEDC_CHANNEL_0;
  c.ledc_timer   = LEDC_TIMER_0;
  c.pin_d0 = CAM_PIN_D0;  c.pin_d1 = CAM_PIN_D1;
  c.pin_d2 = CAM_PIN_D2;  c.pin_d3 = CAM_PIN_D3;
  c.pin_d4 = CAM_PIN_D4;  c.pin_d5 = CAM_PIN_D5;
  c.pin_d6 = CAM_PIN_D6;  c.pin_d7 = CAM_PIN_D7;
  c.pin_xclk     = CAM_PIN_XCLK;
  c.pin_pclk     = CAM_PIN_PCLK;
  c.pin_vsync    = CAM_PIN_VSYNC;
  c.pin_href     = CAM_PIN_HREF;
/*
 * خط اتوبوس I2C را که Wire قبلاً کنترل می‌کند به اشتراک بگذارید (پنل لمسی نیز در آنجا وجود دارد) به جای اینکه دوربین یک درایور دوم را بر روی همان پایه‌ها نصب کند - که باعث غیر فعال شدن لمس می‌شود. Wire.begin() باید قبل از این تابع اجرا شود.
 */
  c.pin_sccb_sda = -1;
  c.pin_sccb_scl = -1;
  c.sccb_i2c_port = 0; // سیم = پورت I2C 0
  c.pin_pwdn     = CAM_PIN_PWDN;
  c.pin_reset    = CAM_PIN_RESET;
  c.xclk_freq_hz = 20000000;
  c.frame_size   = size;
  c.pixel_format = fmt;
  c.grab_mode    = CAMERA_GRAB_WHEN_EMPTY;
  c.fb_location  = CAMERA_FB_IN_PSRAM;
  c.jpeg_quality = quality;
  c.fb_count     = fb_count;

  if (esp_camera_init(&c) != ESP_OK) return false;

  sensor_t *s = esp_camera_sensor_get();
  if (s) {
    s->set_hmirror(s, mirrored ? 1 : 0);
    s->set_vflip(s,   mirrored ? 1 : 0);
    s->set_brightness(s, 1);
  }
  return true;
}

bool camStartPreview() { return camStart(PIXFORMAT_RGB565, FRAMESIZE_240X240, 2, 12); }

/*
 * یک JPEG SVGA را در یک بافر PSRAM که متعلق به فراخواننده است بگیرید.
 * uint8_t *captureJpeg(size_t *len_out) {
 *  esp_camera_deinit();
 *  delay(120);
 *  if (!camStart(PIXFORMAT_JPEG, FRAMESIZE_SVGA, 1, 12)) { *len_out = 0; return nullptr; }
 * 
 *  // چند فریم گرم‌کردن برای تنظیم نوردهی
 *  for (int i = 0; i < 3; i++) {
 *  camera_fb_t *w = esp_camera_fb_get();
 *  if (w) esp_camera_fb_return(w);
 *  delay(100);
 *  }
 * 
 *  uint8_t *copy = nullptr;
 * len_out = 0;
 *  camera_fb_t *fb = esp_camera_fb_get();
 *  if (fb && fb->len > 0) {
 *  copy = (uint8_t *)ps_malloc(fb->len);
 *  if (copy) { memcpy(copy, fb->buf, fb->len); *len_out = fb->len; }
 *  }
 *  if (fb) esp_camera_fb_return(fb);
 * 
 *  /* عمدتاً دوربین را در اینجا خاموش نگه‌دارید. فراخواننده پیش‌نمایش را 
 * پس از تماس ابری دوباره راه‌اندازی می‌کند - یک درایور دوربین در حال اجرا 
 * RAM داخلی را برای handshake TLS خالی می‌کند (این همان اشکال "Vision HTTP -1" بود).
 */
  esp_camera_deinit();
  delay(120);
  return copy;
}


/*
 * خواننده پاسخ HTTP - به اشتراک گذاشته شده توسط تماس‌های ابری زیر. شِفر (کود) وضعیت را بازمی‌گرداند و body_out را پر می‌کند. رمزگذاری انتقال chunked را به درستی مدیریت می‌کند: نشانه‌های اندازه chunk باید حذف شوند، در غیر این صورت درون JSON گنجانده می‌شوند و تجزیه با شکست مواجه می‌شود. (OpenAI پاسخ‌هایش را به صورت زیبایی چاپ می‌کند، بنابراین در چندین chunk گسترش می‌یابند - این ما را در آزمون به دردسر انداخت.)
 */
/*
 * تا زمانی که اتصال داده‌ای نداشته باشد (یا بودجه تمام شود) مسدود شوید. هر خواندنی 
 * در زیر از این طریق می‌گذرد، زیرا سرور ممکن است برای چندین ثانیه ساکت باشد 
 * در حین تجزیه و تحلیل تصویر - و یک read() خالی به سادگی زمان می‌برد.
 */
static bool waitData(WiFiClientSecure &c, uint32_t ms) {
  uint32_t t0 = millis();
  while (!c.available()) {
    if (!c.connected()) return false;
    if (millis() - t0 > ms) return false;
    delay(10);
  }
  return true;
}

static int readHttpResponse(WiFiClientSecure &client, String &body_out, uint32_t idle_ms) {
  body_out = "";

  if (!waitData(client, idle_ms)) { Serial.println("HTTP: no response at all"); return 0; }
  String status_line = client.readStringUntil('\n');
  int code = 0;
  sscanf(status_line.c_str(), "HTTP/%*s %d", &code);

  bool chunked = false;
  while (waitData(client, idle_ms)) {
    String h = client.readStringUntil('\n');
    if (h == "\r" || h.length() <= 1) break; // خط خالی = پایان هدرها
    h.toLowerCase();
    if (h.startsWith("transfer-encoding:") && h.indexOf("chunked") >= 0) chunked = true;
  }

  if (chunked) {
    int blanks = 0;
    while (true) {
/*
 * ایستادن اینجا - به جای اینکه اجازه بدهیم تابع read() زمانش به پایان برسد - ضروری است: یک خواندن زمان‌پریشیده دقیقاً شبیه "0" (قطعه نهایی) به نظر می‌رسد، که به طور خاموش بدنه را به هیچ‌چیز تغییر می‌دهد در حالی که سرور هنوز در حال فکر کردن است.
 */
      if (!waitData(client, idle_ms)) {
        Serial.println("HTTP: timed out waiting for the next chunk");
        break;
      }
      String szline = client.readStringUntil('\n');
      szline.trim();
      if (szline.length() == 0) {
        if (++blanks > 4) break;
        continue;
      }
      blanks = 0;

      long sz = strtol(szline.c_str(), NULL, 16);
      if (sz <= 0) break; // قطعه نهایی واقعی

      long got = 0;
      while (got < sz) {
        if (!waitData(client, idle_ms)) break;
        while (client.available() && got < sz) { body_out += (char)client.read(); got++; }
      }
      if (waitData(client, 3000)) client.readStringUntil('\n');
      if (got < sz) { Serial.println("HTTP: short chunk"); break; }
    }
  } else {
    while (waitData(client, idle_ms))
      while (client.available()) body_out += (char)client.read();
  }
  return code;
}


/*
 * ===========================================================================
 *  تماس تصویری OpenAI
 *  بدنه درخواست به صورت دستی در PSRAM ساخته می‌شود نه از طریق ArduinoJson،
 *  زیرا قرار دادن یک رشته base64 به اندازه 55 کیلوبایت در یک سند JSON به معنای
 *  نگه داشتن دو نسخه است. متن base64 هرگز به escape JSON نیاز ندارد، بنابراین این امن است.
 * ===========================================================================
 */
bool askVision(const uint8_t *jpg, size_t jpg_len, const char *prompt, String &answer_out) {
 // --- تصویر را به کدbase64 در PSRAM تبدیل کنید ---
  size_t b64_cap = ((jpg_len + 2) / 3) * 4 + 16;
  unsigned char *b64 = (unsigned char *)ps_malloc(b64_cap);
  if (!b64) return false;
  size_t b64_len = 0;
  if (mbedtls_base64_encode(b64, b64_cap, &b64_len, jpg, jpg_len) != 0) {
    free(b64);
    return false;
  }

 // --- JSON درخواست را در اطراف آن جمع آوری کنید ---
/*
 * مدل‌های فعلی OpenAI نام قدیمی "max_tokens" را رد می‌کنند - باید "max_completion_tokens" باشد (این موضوع در برابر API زنده تایید شده است، جولای 2026).
 */
  const char *head_fmt =
      "{\"model\":\"%s\",\"max_completion_tokens\":%d,\"messages\":[{\"role\":\"user\","
      "\"content\":[{\"type\":\"text\",\"text\":\"%s\"},"
      "{\"type\":\"image_url\",\"image_url\":{\"url\":\"data:image/jpeg;base64,";
  const char *tail = "\"}}]}]}";

  char head[640];
  int head_len = snprintf(head, sizeof(head), head_fmt, OPENAI_MODEL, LLM_MAX_TOKENS, prompt);

  size_t body_len = head_len + b64_len + strlen(tail);
  char *body = (char *)ps_malloc(body_len + 1);
  if (!body) { free(b64); return false; }
  memcpy(body, head, head_len);
  memcpy(body + head_len, b64, b64_len);
  strcpy(body + head_len + b64_len, tail);
  free(b64);

 // --- ارسال کنید: HTTP دستی، بارگذاری به صورت قسمتی (الگوی اثبات شده از 04) ---
  WiFiClientSecure client;
  client.setInsecure();
  client.setTimeout(25);

  if (!client.connect(OPENAI_HOST, 443)) {
    Serial.println("Vision: TLS connect failed (is the camera still running?)");
    free(body);
    return false;
  }

  client.print(String("POST /v1/chat/completions HTTP/1.1\r\n"
               "Host: " OPENAI_HOST "\r\n"
               "Authorization: Bearer " OPENAI_KEY "\r\n"
               "Content-Type: application/json\r\n"
               "Connection: close\r\n"
               "Content-Length: ") + String(body_len) + "\r\n\r\n");

  size_t sent = 0;
  while (sent < body_len) {
    size_t n = min((size_t)4096, body_len - sent);
    size_t w = client.write((uint8_t *)body + sent, n);
    if (w == 0) {
      delay(50);
      w = client.write((uint8_t *)body + sent, n);
      if (w == 0) break;
    }
    sent += w;
    yield();
  }
  free(body);
  if (sent < body_len) {
    Serial.printf("Vision: upload stalled at %u/%u bytes\n",
                  (unsigned)sent, (unsigned)body_len);
    client.stop();
    return false;
  }

/*
 * پاسخ را با تفکیک مناسب بخوانید */
 *  String resp;
 *  int code = readHttpResponse(client, resp, 30000);
 *  client.stop();
 * 
 *  if (code != 200) {
 *  Serial.printf("Vision HTTP %d: %s\n", code, resp.c_str());
 *  return false;
 *  }
 * 
 *  bool ok = false;
 *  JsonDocument doc;
 *  if (!deserializeJson(doc, resp)) {
 *  const char *content = doc["choices"][0]["message"]["content"];
 *  if (content) {
 *  answer_out = String(content);
 *  answer_out.trim();
 *  ok = answer_out.length() > 0;
 *  }
 *  } else {
 *  Serial.printf("Vision: تجزیه JSON شکست خورد، %u بایت دریافت شد\n", resp.length());
 *  }
 *  return ok;
 * }
 * 
 * /* =========================================================================== 
 *  Azure TTS - ترفند استریم مشابه با طرح 04: PCM خام به I2S.
 * ===========================================================================
 */
#if SPEAK_REPLIES
void spkInit() {
  i2s_config_t cfg = {
    .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX),
    .sample_rate = 16000,
    .bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT,
    .channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,
    .communication_format = I2S_COMM_FORMAT_STAND_I2S,
    .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
    .dma_buf_count = 8,
    .dma_buf_len = 256,
    .use_apll = false,
    .tx_desc_auto_clear = true,
    .fixed_mclk = 0
  };
  i2s_pin_config_t pins = {
    .mck_io_num = I2S_PIN_NO_CHANGE,
    .bck_io_num = I2S_SPK_BCLK,
    .ws_io_num = I2S_SPK_LRC,
    .data_out_num = I2S_SPK_DOUT,
    .data_in_num = I2S_PIN_NO_CHANGE
  };
  i2s_driver_install(I2S_SPK_PORT, &cfg, 0, NULL);
  i2s_set_pin(I2S_SPK_PORT, &pins);
  i2s_zero_dma_buffer(I2S_SPK_PORT);
}

/*
 * دقیقاً n بایت از یک کلاینت بخوانید (یا تا زمان تایم‌اوت). */
 * static size_t readExact(WiFiClientSecure &c, uint8_t *dst, size_t n) {
 *  size_t got = 0;
 *  uint32_t t0 = millis();
 *  while (got < n && millis() - t0 < 10000) {
 *  int r = c.read(dst + got, n - got);
 *  if (r > 0) { got += r; t0 = millis(); }
 *  else if (!c.connected() && !c.available()) break;
 *  else delay(2);
 *  }
 *  return got;
 * }
 * 
 * void playLastAnswer(); // تعریف شده در زیر؛ پروتوتایپ صریح برای IDE
 * 
 * void speak(const String &text) {
 *  String safe = text;
 *  safe.replace("&", "&amp;");
 *  safe.replace("<", "&lt;");
 *  safe.replace(">", "&gt;");
 *  String ssml = "<speak version='1.0' xml:lang='" AZURE_TTS_LANG "'>"
 *  "<voice name='" AZURE_TTS_VOICE "'>" + safe + "</voice></speak>";
 * 
 *  /* HTTP دستی با حذف صحیح بخش‌ها: Azure این صدا را به صورت بسته‌بندی ارسال می‌کند و
 * جریان خام HTTPClient خطوط اندازه‌ی بسته‌ی ASCII را به PCM نشت می‌دهد -
 * هر یک به صورت قابل شنیدنی به عنوان یک ضربه شنیده می‌شود.
 */
  WiFiClientSecure client;
  client.setInsecure();
  client.setTimeout(20);

  const char *host = AZURE_REGION ".tts.speech.microsoft.com";
  if (!client.connect(host, 443)) { Serial.println("TTS: TLS connect failed"); return; }

  client.print(String("POST /cognitiveservices/v1 HTTP/1.1\r\n"
               "Host: ") + host + "\r\n"
               "Ocp-Apim-Subscription-Key: " AZURE_SPEECH_KEY "\r\n"
               "Content-Type: application/ssml+xml\r\n"
               "X-Microsoft-OutputFormat: riff-16khz-16bit-mono-pcm\r\n"
               "User-Agent: MaTouchRobojax\r\n"
               "Connection: close\r\n"
               "Content-Length: " + String(ssml.length()) + "\r\n\r\n");
  client.print(ssml);

  String status_line = client.readStringUntil('\n');
  int code = 0;
  sscanf(status_line.c_str(), "HTTP/%*s %d", &code);
  bool chunked = false;
  long content_len = -1;
  while (client.connected() || client.available()) {
    String h = client.readStringUntil('\n');
    if (h == "\r" || h.length() <= 1) break;
    h.toLowerCase();
    if (h.startsWith("transfer-encoding:") && h.indexOf("chunked") >= 0) chunked = true;
    if (h.startsWith("content-length:")) content_len = h.substring(15).toInt();
  }
  if (code != 200) { Serial.printf("TTS HTTP %d\n", code); client.stop(); return; }

  const size_t AUDIO_CAP = 1200 * 1024;
  uint8_t *audio = (uint8_t *)ps_malloc(AUDIO_CAP);
  if (!audio) { client.stop(); return; }
  size_t alen = 0;

  if (chunked) {
    while (true) {
      String szline = client.readStringUntil('\n');
      long sz = strtol(szline.c_str(), NULL, 16);
      if (sz <= 0) break;
      if (alen + sz > AUDIO_CAP) break;
      size_t got = readExact(client, audio + alen, sz);
      alen += got;
      client.readStringUntil('\n');
      if (got < (size_t)sz) break;
    }
  } else if (content_len > 0) {
    alen = readExact(client, audio, min((size_t)content_len, AUDIO_CAP));
  } else {
    uint32_t idle = millis();
    while ((client.connected() || client.available()) && millis() - idle < 5000) {
      int r = client.read(audio + alen, min((size_t)2048, AUDIO_CAP - alen));
      if (r > 0) { alen += r; idle = millis(); }
      else delay(5);
    }
  }
  client.stop();

  if (alen > WAV_HEADER_LEN) {
/*
 * این پاسخ را برای دکمه REPLAY نگه‌دارید (به جای پاسخ قبلی)، سپس آن را پلی کنید.
 */
    if (last_audio) free(last_audio);
    last_audio = audio;
    last_audio_len = alen;
    playLastAnswer();
  } else {
    free(audio);
  }
}

/*
 * پاسخ نگهداری شده از PSRAM را پخش کنید - بلافاصله پس از دانلود و توسط REPLAY استفاده شده است. */
 * void playLastAnswer() {
 *  if (!last_audio || last_audio_len <= WAV_HEADER_LEN) return;
 *  static const uint8_t lead_in[640] = {0}; // 20 میلی‌ثانیه سکوت پیش‌نویس
 *  size_t w = 0;
 *  i2s_write(I2S_SPK_PORT, lead_in, sizeof(lead_in), &w, portMAX_DELAY);
 *  i2s_write(I2S_SPK_PORT, last_audio + WAV_HEADER_LEN,
 *  last_audio_len - WAV_HEADER_LEN, &w, portMAX_DELAY);
 *  i2s_write(I2S_SPK_PORT, lead_in, sizeof(lead_in), &w, portMAX_DELAY);
 *  delay(150);
 *  i2s_zero_dma_buffer(I2S_SPK_PORT);
 * }
 * #endif // SPEAK_REPLIES
 * 
 * /* ===========================================================================
 *  رابط کاربری
 * ===========================================================================
 */
void drawButton(int y, const char *l1, const char *l2, uint16_t colour) {
  gfx->fillRoundRect(BTN_X, y, BTN_W, BTN_H, 6, colour);
  gfx->drawRoundRect(BTN_X, y, BTN_W, BTN_H, 6, WHITE);
  gfx->setTextSize(1);
  gfx->setTextColor(WHITE);
  gfx->setCursor(BTN_X + 8, y + 14);
  gfx->print(l1);
  gfx->setCursor(BTN_X + 8, y + 28);
  gfx->print(l2);
}

/*
 * بارهای WiFi + dBm در پایین ستون کناری، از حلقه به‌روز شده است */
 * void drawWifi() {
 *  gfx->fillRect(BTN_X, 188, BTN_W, 52, BLACK);
 *  bool up = (WiFi.status() == WL_CONNECTED);
 *  long rssi = up ? WiFi.RSSI() : -100;
 *  int bars = rssi > -55 ? 4 : rssi > -65 ? 3 : rssi > -75 ? 2 : rssi > -85 ? 1 : 0;
 * 
 *  for (int b = 0; b < 4; b++) {
 *  int bh = 6 + b * 6;
 *  uint16_t col = (b < bars) ? GREEN : gfx->color565(60, 60, 60);
 *  gfx->fillRect(BTN_X + 4 + b * 9, 216 - bh, 7, bh, col);
 *  }
 *  gfx->setTextSize(1);
 *  gfx->setCursor(BTN_X + 44, 196);
 *  if (up) {
 *  gfx->setTextColor(CYAN);
 *  gfx->printf("%ld", rssi);
 *  } else {
 *  gfx->setTextColor(RED);
 *  gfx->print("DOWN");
 *  }
 *  gfx->setCursor(BTN_X + 44, 208);
 *  gfx->setTextColor(gfx->color565(120, 120, 120));
 *  gfx->print("dBm");
 *  gfx->setCursor(BTN_X + 4, 228);
 *  gfx->setTextColor(gfx->color565(120, 120, 120));
 *  gfx->print("سیگنال WiFi");
 * }
 * 
 * /* ستون کناری معمولی: دو دکمه درخواست */
 */
void drawButtons() {
  gfx->fillRect(BTN_X, 0, BTN_W, 188, BLACK);
  drawButton(BTN_OBJ_Y,    "WHAT DO",  "YOU SEE?", gfx->color565(0, 90, 160));
  drawButton(BTN_PERSON_Y, "DESCRIBE", "PERSON",   gfx->color565(120, 60, 140));

  gfx->setTextColor(CYAN);
  gfx->setCursor(BTN_X + 2, 128);
  gfx->print("OpenAI eyes");
  gfx->setCursor(BTN_X + 2, 140);
  gfx->print("Azure voice");
  gfx->setCursor(BTN_X + 2, 152);
  gfx->print("Robojax.com");

  drawWifi();
}

/*
 * حالت پاسخ در ستون کناری: پاک کردن (بازگشت به دوربین) + پخش مجدد (دوباره بگویید).  
 * پاسخ بر روی صفحه باقی می‌ماند تا زمانی که پاک کردن لمس شود.
 */
void drawClearSide() {
  gfx->fillRect(BTN_X, 0, BTN_W, 188, BLACK);
  gfx->fillRoundRect(BTN_X, BTN_OBJ_Y, BTN_W, BTN_H, 6, gfx->color565(110, 35, 35));
  gfx->drawRoundRect(BTN_X, BTN_OBJ_Y, BTN_W, BTN_H, 6, WHITE);
  gfx->setTextSize(2);
  gfx->setTextColor(WHITE);
  gfx->setCursor(BTN_X + 10, BTN_OBJ_Y + 14);
  gfx->print("CLEAR");

#if SPEAK_REPLIES
  if (last_audio_len > 0) {
    gfx->fillRoundRect(BTN_X, BTN_PERSON_Y, BTN_W, BTN_H, 6, gfx->color565(0, 110, 60));
    gfx->drawRoundRect(BTN_X, BTN_PERSON_Y, BTN_W, BTN_H, 6, WHITE);
    gfx->setTextSize(1);
    gfx->setTextColor(WHITE);
    gfx->setCursor(BTN_X + 16, BTN_PERSON_Y + 20);
    gfx->print("REPLAY");
  }
#endif

  gfx->setTextSize(1);
  gfx->setTextColor(YELLOW);
  gfx->setCursor(BTN_X + 2, 124);
  gfx->print("CLEAR = camera");
  gfx->setCursor(BTN_X + 2, 136);
  gfx->print("REPLAY = again");

  drawWifi();
}

/*
 * پاسخWrap شده در پایین نماگر. 
 * void showAnswer(const String &text) {
 *  const int max_chars = 38;
 *  int lines = (text.length() + max_chars - 1) / max_chars;
 *  if (lines > 6) lines = 6;
 *  int h = lines * 10 + 8;
 *  int y = 240 - h;
 * 
 *  gfx->fillRect(0, y, 240, h, gfx->color565(0, 0, 0));
 *  gfx->drawRect(0, y, 240, h, CYAN);
 *  gfx->setTextSize(1);
 *  gfx->setTextColor(WHITE);
 *  for (int i = 0; i < lines; i++) {
 *  gfx->setCursor(4, y + 5 + i * 10);
 *  gfx->print(text.substring(i * max_chars, min((int)text.length(), (i + 1) * max_chars)));
 *  }
 * }
 * 
 * /* ===========================================================================
 *  یک چرخه کامل "نگاه و بگو"
 * ===========================================================================
 */
void lookAndTell(const char *prompt, const char *label) {
  led(255, 120, 0); // کهربا: کار کردن
  preview_on = false; // دوربین اینجا خاموش می‌شود

  gfx->fillRect(0, 0, 240, 240, BLACK);
  gfx->setTextColor(YELLOW);
  gfx->setTextSize(1);
  gfx->setCursor(30, 110);
  gfx->printf("capturing photo (%s)...", label);

  size_t jpg_len = 0;
  uint32_t t_cap = millis();
  uint8_t *jpg = captureJpeg(&jpg_len);
  t_cap = millis() - t_cap;

  if (!jpg || jpg_len == 0) {
    if (jpg) free(jpg);
    showAnswer("Capture failed - tap CLEAR to retry.");
    led(255, 0, 0);
    drawClearSide();
    return;
  }
  Serial.printf("Captured %u KB in %lu ms\n", (unsigned)(jpg_len / 1024), (unsigned long)t_cap);

  gfx->setCursor(30, 124);
  gfx->printf("asking OpenAI (%uKB)...", (unsigned)(jpg_len / 1024));

  String answer;
  uint32_t t_ai = millis();
  bool ok = askVision(jpg, jpg_len, prompt, answer);
  t_ai = millis() - t_ai;
  free(jpg);

  if (!ok) {
    showAnswer("No answer - see serial monitor for the reason.");
    led(255, 0, 0);
    drawClearSide();
    return;
  }

  Serial.printf("Vision (%lu ms): %s\n", (unsigned long)t_ai, answer.c_str());
  showAnswer(answer);
  drawClearSide(); // CLEAR د دکمه‌های درخواست را جایگزین می‌کند

#if SPEAK_REPLIES
  led(0, 255, 40); // سبز: سخن گفتن
  speak(answer); // پاسخ بر روی صفحه نمایش باقی می‌ماند
#endif
  led(0, 0, 0);
/*
 * جواب اکنون تا زمانی که کاربر دکمه CLEAR را بزند باقی می‌ماند */ }
 * /* =========================================================================== 
 * آماده‌سازی 
 * ===========================================================================
 */
void setup() {
  Serial.begin(115200);
  delay(400);
  Serial.println("\n=== 05 Vision AI  |  Robojax.com ===");

  pinMode(TFT_BLK, OUTPUT);
  digitalWrite(TFT_BLK, LOW);
  pinMode(SD_CS, OUTPUT);
  digitalWrite(SD_CS, HIGH);

  gfx->begin();
  gfx->fillScreen(BLACK);
  digitalWrite(TFT_BLK, HIGH);

  bbct.init(TOUCH_SDA, TOUCH_SCL, TOUCH_RST, TOUCH_INT);
  delay(50);

 // اس اس سی بی دوربین این باس را به اشتراک می‌گذارد، بنابراین وایر باید قبل از دوربین روشن باشد.
  Wire.begin(I2C_SDA, I2C_SCL, 100000);
  delay(20);

  rgb.begin();
  rgb.setBrightness(LED_BRIGHTNESS);
  led(0, 0, 0);

#if SPEAK_REPLIES
  spkInit();
#endif

  gfx->setTextSize(1);
  gfx->setTextColor(YELLOW);
  gfx->setCursor(4, 4);
  gfx->printf("Connecting to %s ...", WIFI_SSID);

  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  uint32_t t0 = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - t0 < 20000) delay(300);

  gfx->setCursor(4, 16);
  if (WiFi.status() == WL_CONNECTED) {
    gfx->setTextColor(GREEN);
    gfx->print("WiFi ok");
  } else {
    gfx->setTextColor(RED);
    gfx->print("WiFi FAILED (2.4GHz only! check secrets.h)");
  }

  gfx->setCursor(4, 28);
  gfx->setTextColor(YELLOW);
  gfx->print("starting camera...");
  if (!camStartPreview()) {
    gfx->fillScreen(RED);
    gfx->setTextColor(WHITE);
    gfx->setTextSize(2);
    gfx->setCursor(20, 100);
    gfx->print("CAMERA FAILED");
    gfx->setTextSize(1);
    gfx->setCursor(20, 130);
    gfx->print("Press RESET (camera reset = board reset)");
    while (1) delay(1000);
  }

  delay(400);
  gfx->fillScreen(BLACK);
  drawButtons();
  preview_on = true;
  Serial.println("Running. Touch a button to have the AI look through the camera.");
}


/*
 * حلقه
 */
void loop() {
  static uint32_t last_touch = 0;

  if (preview_on) {
    camera_fb_t *fb = esp_camera_fb_get();
    if (fb) {
      gfx->draw16bitBeRGBBitmap(0, 0, (uint16_t *)fb->buf, fb->width, fb->height);
      esp_camera_fb_return(fb);
    }
  } else {
    delay(20); // پاسخ بر روی صفحه نمایش، در انتظار CLEAR
  }

/*
 * سیگنال WiFi زنده، هر ۲ ثانیه در هر دو حالت تازه‌سازی می‌شود */
 *  static uint32_t last_wifi = 0;
 *  اگر (millis() - last_wifi > 2000) {
 *  last_wifi = millis();
 *  drawWifi();
 *  }
 * 
 *  /* لمس شناسایی شده با لبه: یک عمل تنها در هنگام تماس جدید با انگشت پایین فعال می‌شود و
 * انگشت باید به طور کامل بلند شود (۴ خواندن خالی متوالی) قبل از اینکه هر عملی دوباره فعال شود. این همان چیزی است که یک بار ضربه بر روی CLEAR را از فعال کردن
 * دکمه ضبط که چند لحظه بعد در همان مکان ظاهر می‌شود، متوقف می‌کند.
 */
  static bool    touch_down = false;
  static uint8_t release_count = 0;

  uint16_t x, y;
  if (getTouch(&x, &y)) {
    release_count = 0;
    if (!touch_down) {
      touch_down = true; // تپ جدید - دقیقا یک بار عمل کن

      if (!preview_on) {

مواردی که ممکن است به آن‌ها نیاز داشته باشید

منابع و مراجع

فایل‌ها📁

فایل مورد نیاز (.h)

  • secrets.h
    file for Makerfabs MaTouch AI ESP32S3 2.8" TFT Camera module
    secrets.h 0.01 MB

سایر فایل‌ها

  • pins.h
    pins file for MaTouch AI ESP32S3 2.8" camera LCD touch screen.
    pins.h 0.01 MB

شماتیک

  • MaTouch_AI 2.8“ MaTouch AI ESP32S3 2.8" TFT ST7789V schematic
    The latest MaTouch AI board integrate I2S voice input/I2S speaker/ 3 million camera OV3660/ 320*240 resolution display, with ESP32S3 strong processor& Wifi ability, to make this board a good tool/platform for AI development with ESP32.
    MaTouch_AI 2.8“ SPI TFT ST7789V V1.1.PDF 0.15 MB