From 54d6d51519a65c8cab6af187a8286d85b5ded2a1 Mon Sep 17 00:00:00 2001 From: quantenschaum <woxpox@posteo.de> Date: Wed, 21 Aug 2024 14:57:36 +0200 Subject: [PATCH 01/58] tried to fix wind conversion, disabled wind calculation --- lib/boatData/GwBoatData.h | 53 +++++++++++++------------ lib/nmea0183ton2k/NMEA0183DataToN2K.cpp | 37 ++++++++--------- lib/nmea2kto0183/N2kDataToNMEA0183.cpp | 41 +++++++++++-------- 3 files changed, 69 insertions(+), 62 deletions(-) diff --git a/lib/boatData/GwBoatData.h b/lib/boatData/GwBoatData.h index 6d3ab19..fd57a87 100644 --- a/lib/boatData/GwBoatData.h +++ b/lib/boatData/GwBoatData.h @@ -175,41 +175,42 @@ class GwBoatData{ GwBoatItemBase::GwBoatItemMap values; public: - GWBOATDATA(double,COG,4000,formatCourse) - GWBOATDATA(double,TWD,4000,formatCourse) - GWBOATDATA(double,SOG,4000,formatKnots) - GWBOATDATA(double,STW,4000,formatKnots) - GWBOATDATA(double,TWS,4000,formatKnots) - GWBOATDATA(double,AWS,4000,formatKnots) - GWBOATDATA(double,MaxTws,0,formatKnots) + GWBOATDATA(double,COG,4000,formatCourse) // course over ground + GWBOATDATA(double,SOG,4000,formatKnots) // speed over ground + GWBOATDATA(double,HDG,4000,formatCourse) // true heading + GWBOATDATA(double,MHDG,4000,formatCourse) // magnetic heading + GWBOATDATA(double,STW,4000,formatKnots) // water speed + GWBOATDATA(double,VAR,4000,formatCourse) // variation + GWBOATDATA(double,DEV,4000,formatCourse) // deviation + GWBOATDATA(double,AWA,4000,formatWind) // apparent wind ANGLE + GWBOATDATA(double,AWS,4000,formatKnots) // apparent wind speed GWBOATDATA(double,MaxAws,0,formatKnots) - GWBOATDATA(double,AWA,4000,formatWind) - GWBOATDATA(double,HDG,4000,formatCourse) //true heading - GWBOATDATA(double,MHDG,4000,formatCourse) //magnetic heading - GWBOATDATA(double,ROT,4000,formatRot) - GWBOATDATA(double,VAR,4000,formatCourse) //Variation - GWBOATDATA(double,DEV,4000,formatCourse) //Deviation + GWBOATDATA(double,TWD,4000,formatCourse) // true wind DIRECTION + GWBOATDATA(double,TWA,4000,formatCourse) // true wind ANGLE + GWBOATDATA(double,TWS,4000,formatKnots) // true wind speed + GWBOATDATA(double,MaxTws,0,formatKnots) + GWBOATDATA(double,ROT,4000,formatRot) // rate of turn + GWBOATDATA(double,RPOS,4000,formatWind) // rudder position + GWBOATDATA(double,PRPOS,4000,formatWind) // secondary rudder position + GWBOATDATA(double,LAT,4000,formatLatitude) + GWBOATDATA(double,LON,4000,formatLongitude) + GWBOATDATA(double,ALT,4000,formatFixed0) //altitude GWBOATDATA(double,HDOP,4000,formatDop) GWBOATDATA(double,PDOP,4000,formatDop) GWBOATDATA(double,VDOP,4000,formatDop) - GWBOATDATA(double,RPOS,4000,formatWind) //RudderPosition - GWBOATDATA(double,PRPOS,4000,formatWind) //second rudder pos - GWBOATDATA(double,LAT,4000,formatLatitude) - GWBOATDATA(double,LON,4000,formatLongitude) - GWBOATDATA(double,ALT,4000,formatFixed0) //altitude GWBOATDATA(double,DBS,4000,formatDepth) //waterDepth (below surface) GWBOATDATA(double,DBT,4000,formatDepth) //DepthTransducer - GWBOATDATA(double,GPST,4000,formatTime) //GpsTime + GWBOATDATA(double,GPST,4000,formatTime) // GPS time (seconds of day) + GWBOATDATA(uint32_t,GPSD,4000,formatDate) // GPS date (days since 1979-01-01) + GWBOATDATA(int16_t,TZ,8000,formatFixed0) GWBOATDATA(double,WTemp,4000,kelvinToC) - GWBOATDATA(double,XTE,4000,formatXte) - GWBOATDATA(double,DTW,4000,mtr2nm) //distance wp - GWBOATDATA(double,BTW,4000,formatCourse) //bearing wp - GWBOATDATA(double,WPLat,4000,formatLatitude) - GWBOATDATA(double,WPLon,4000,formatLongitude) GWBOATDATA(uint32_t,Log,16000,mtr2nm) GWBOATDATA(uint32_t,TripLog,16000,mtr2nm) - GWBOATDATA(uint32_t,GPSD,4000,formatDate) //Date - GWBOATDATA(int16_t,TZ,8000,formatFixed0) + GWBOATDATA(double,DTW,4000,mtr2nm) // distance to waypoint + GWBOATDATA(double,BTW,4000,formatCourse) // bearing to waypoint + GWBOATDATA(double,XTE,4000,formatXte) // cross track error + GWBOATDATA(double,WPLat,4000,formatLatitude) // waypoint latitude + GWBOATDATA(double,WPLon,4000,formatLongitude) // waypoint longitude GWSPECBOATDATA(GwBoatDataSatList,SatInfo,GwSatInfoList::lifeTime,formatFixed0); public: GwBoatData(GwLog *logger); diff --git a/lib/nmea0183ton2k/NMEA0183DataToN2K.cpp b/lib/nmea0183ton2k/NMEA0183DataToN2K.cpp index bfed8fd..8477ab8 100644 --- a/lib/nmea0183ton2k/NMEA0183DataToN2K.cpp +++ b/lib/nmea0183ton2k/NMEA0183DataToN2K.cpp @@ -399,20 +399,20 @@ private: return; } tN2kMsg n2kMsg; - tN2kWindReference n2kRef; + tN2kWindReference n2kRef; bool shouldSend=false; WindAngle=formatDegToRad(WindAngle); switch(Reference){ case NMEA0183Wind_Apparent: n2kRef=N2kWind_Apparent; shouldSend=updateDouble(boatData->AWA,WindAngle,msg.sourceId) && - updateDouble(boatData->AWS,WindSpeed,msg.sourceId); + updateDouble(boatData->AWS,WindSpeed,msg.sourceId); if (WindSpeed != NMEA0183DoubleNA) boatData->MaxAws->updateMax(WindSpeed); break; case NMEA0183Wind_True: - n2kRef=N2kWind_True_North; - shouldSend=updateDouble(boatData->TWD,WindAngle,msg.sourceId) && - updateDouble(boatData->TWS,WindSpeed,msg.sourceId); + n2kRef=N2kWind_True_water; + shouldSend=updateDouble(boatData->TWA,WindAngle,msg.sourceId) && + updateDouble(boatData->TWS,WindSpeed,msg.sourceId); if (WindSpeed != NMEA0183DoubleNA) boatData->MaxTws->updateMax(WindSpeed); break; default: @@ -467,8 +467,7 @@ private: void convertMWD(const SNMEA0183Msg &msg) { - double WindAngle = NMEA0183DoubleNA, WindAngleMagnetic=NMEA0183DoubleNA, - WindSpeed = NMEA0183DoubleNA; + double WindDirection = NMEA0183DoubleNA, WindDirectionMagnetic=NMEA0183DoubleNA, WindSpeed = NMEA0183DoubleNA; if (msg.FieldCount() < 8 ) { LOG_DEBUG(GwLog::DEBUG, "failed to parse MWD %s", msg.line); @@ -476,11 +475,11 @@ private: } if (msg.FieldLen(0) > 0 && msg.Field(1)[0] == 'T') { - WindAngle = formatDegToRad(atof(msg.Field(0))); + WindDirection = formatDegToRad(atof(msg.Field(0))); } if (msg.FieldLen(2) > 0 && msg.Field(3)[0] == 'M') { - WindAngleMagnetic = formatDegToRad(atof(msg.Field(2))); + WindDirectionMagnetic = formatDegToRad(atof(msg.Field(2))); } if (msg.FieldLen(4) > 0 && msg.Field(5)[0] == 'N') { @@ -497,19 +496,17 @@ private: } tN2kMsg n2kMsg; bool shouldSend = false; - if (WindAngle != NMEA0183DoubleNA){ - shouldSend = updateDouble(boatData->TWD, WindAngle, msg.sourceId) && + if (WindDirection != NMEA0183DoubleNA){ + shouldSend = updateDouble(boatData->TWD, WindDirection, msg.sourceId) && updateDouble(boatData->TWS, WindSpeed, msg.sourceId); if (WindSpeed != NMEA0183DoubleNA) boatData->MaxTws->updateMax(WindSpeed); - } - if (shouldSend) - { - SetN2kWindSpeed(n2kMsg, 1, WindSpeed, WindAngle, N2kWind_True_North); - send(n2kMsg,msg.sourceId,String(n2kMsg.PGN)+String((int)N2kWind_True_North)); - } - if (WindAngleMagnetic != NMEA0183DoubleNA && shouldSend){ - SetN2kWindSpeed(n2kMsg, 1, WindSpeed, WindAngleMagnetic, N2kWind_Magnetic); - send(n2kMsg,msg.sourceId,String(n2kMsg.PGN)+String((int)N2kWind_Magnetic)); + if(shouldSend && boatData->HDG->isValid()) { + double twa = WindDirection-boatData->HDG->getData(); + if(twa<0) { twa+=2*M_PI; } + updateDouble(boatData->TWA, twa, msg.sourceId); + SetN2kWindSpeed(n2kMsg, 1, WindSpeed, twa, N2kWind_True_water); + send(n2kMsg,msg.sourceId,String(n2kMsg.PGN)+String((int)N2kWind_True_water)); + } } } diff --git a/lib/nmea2kto0183/N2kDataToNMEA0183.cpp b/lib/nmea2kto0183/N2kDataToNMEA0183.cpp index 26c6811..b08ac7d 100644 --- a/lib/nmea2kto0183/N2kDataToNMEA0183.cpp +++ b/lib/nmea2kto0183/N2kDataToNMEA0183.cpp @@ -468,37 +468,46 @@ private: { unsigned char SID; tN2kWindReference WindReference; - tNMEA0183WindReference NMEA0183Reference = NMEA0183Wind_True; - - double x, y; double WindAngle=N2kDoubleNA, WindSpeed=N2kDoubleNA; - if (ParseN2kWindSpeed(N2kMsg, SID, WindSpeed, WindAngle, WindReference)) - { + if (ParseN2kWindSpeed(N2kMsg, SID, WindSpeed, WindAngle, WindReference)) { tNMEA0183Msg NMEA0183Msg; + tNMEA0183WindReference NMEA0183Reference; + bool shouldSend = false; - if (WindReference == N2kWind_Apparent) - { + // MWV sentence contains apparent/true ANGLE and SPEED + // https://gpsd.gitlab.io/gpsd/NMEA.html#_mwv_wind_speed_and_angle + // https://docs.vaisala.com/r/M211109EN-L/en-US/GUID-7402DEF8-5E82-446F-B63E-998F49F3D743/GUID-C77934C7-2A72-466E-BC52-CE6B8CC7ACB6 + + if (WindReference == N2kWind_Apparent) { NMEA0183Reference = NMEA0183Wind_Apparent; updateDouble(boatData->AWA, WindAngle); updateDouble(boatData->AWS, WindSpeed); setMax(boatData->MaxAws, boatData->AWS); - } - if (WindReference == N2kWind_True_North) - { + shouldSend = true; + } + if (WindReference == N2kWind_True_water) { NMEA0183Reference = NMEA0183Wind_True; - updateDouble(boatData->TWD, WindAngle); + updateDouble(boatData->TWA, WindAngle); updateDouble(boatData->TWS, WindSpeed); + setMax(boatData->MaxTws, boatData->TWS); + shouldSend = true; + if (boatData->HDG->isValid()) { + double twd = WindAngle+boatData->HDG->getData(); + if (twd>2*M_PI) { twd-=2*M_PI; } + updateDouble(boatData->TWD, twd); + } } - if (NMEA0183SetMWV(NMEA0183Msg, formatCourse(WindAngle), NMEA0183Reference, WindSpeed,talkerId)) + if (shouldSend && NMEA0183SetMWV(NMEA0183Msg, formatCourse(WindAngle), NMEA0183Reference, WindSpeed, talkerId)) { SendMessage(NMEA0183Msg); + } - if (WindReference == N2kWind_Apparent && boatData->SOG->isValid()) + /* if (WindReference == N2kWind_Apparent && boatData->SOG->isValid()) { // Lets calculate and send TWS/TWA if SOG is available - x = WindSpeed * cos(WindAngle); - y = WindSpeed * sin(WindAngle); + double x = WindSpeed * cos(WindAngle); + double y = WindSpeed * sin(WindAngle); updateDouble(boatData->TWD, atan2(y, -boatData->SOG->getData() + x)); updateDouble(boatData->TWS, sqrt((y * y) + ((-boatData->SOG->getData() + x) * (-boatData->SOG->getData() + x)))); @@ -534,7 +543,7 @@ private: return; SendMessage(NMEA0183Msg); - } + } */ } } //***************************************************************************** From 1fa10497594d4155fc4b345b6443f870c248bf68 Mon Sep 17 00:00:00 2001 From: quantenschaum <woxpox@posteo.de> Date: Thu, 22 Aug 2024 15:16:23 +0200 Subject: [PATCH 02/58] renamed HDG->HDT, MHDG->HDM --- lib/boatData/GwBoatData.h | 4 +-- lib/nmea0183ton2k/NMEA0183DataToN2K.cpp | 35 +++++++++++++------------ lib/nmea2kto0183/N2kDataToNMEA0183.cpp | 16 +++++------ 3 files changed, 28 insertions(+), 27 deletions(-) diff --git a/lib/boatData/GwBoatData.h b/lib/boatData/GwBoatData.h index fd57a87..b4df0ec 100644 --- a/lib/boatData/GwBoatData.h +++ b/lib/boatData/GwBoatData.h @@ -177,8 +177,8 @@ class GwBoatData{ GWBOATDATA(double,COG,4000,formatCourse) // course over ground GWBOATDATA(double,SOG,4000,formatKnots) // speed over ground - GWBOATDATA(double,HDG,4000,formatCourse) // true heading - GWBOATDATA(double,MHDG,4000,formatCourse) // magnetic heading + GWBOATDATA(double,HDT,4000,formatCourse) // true heading + GWBOATDATA(double,HDM,4000,formatCourse) // magnetic heading GWBOATDATA(double,STW,4000,formatKnots) // water speed GWBOATDATA(double,VAR,4000,formatCourse) // variation GWBOATDATA(double,DEV,4000,formatCourse) // deviation diff --git a/lib/nmea0183ton2k/NMEA0183DataToN2K.cpp b/lib/nmea0183ton2k/NMEA0183DataToN2K.cpp index 8477ab8..7868eaf 100644 --- a/lib/nmea0183ton2k/NMEA0183DataToN2K.cpp +++ b/lib/nmea0183ton2k/NMEA0183DataToN2K.cpp @@ -500,8 +500,8 @@ private: shouldSend = updateDouble(boatData->TWD, WindDirection, msg.sourceId) && updateDouble(boatData->TWS, WindSpeed, msg.sourceId); if (WindSpeed != NMEA0183DoubleNA) boatData->MaxTws->updateMax(WindSpeed); - if(shouldSend && boatData->HDG->isValid()) { - double twa = WindDirection-boatData->HDG->getData(); + if(shouldSend && boatData->HDT->isValid()) { + double twa = WindDirection-boatData->HDT->getData(); if(twa<0) { twa+=2*M_PI; } updateDouble(boatData->TWA, twa, msg.sourceId); SetN2kWindSpeed(n2kMsg, 1, WindSpeed, twa, N2kWind_True_water); @@ -511,15 +511,15 @@ private: } void convertHDM(const SNMEA0183Msg &msg){ - double MHDG=NMEA0183DoubleNA; - if (!NMEA0183ParseHDM_nc(msg, MHDG)) + double HDM=NMEA0183DoubleNA; + if (!NMEA0183ParseHDM_nc(msg, HDM)) { LOG_DEBUG(GwLog::DEBUG, "failed to parse HDM %s", msg.line); return; } - if (! UD(MHDG)) return; + if (! UD(HDM)) return; tN2kMsg n2kMsg; - SetN2kMagneticHeading(n2kMsg,1,MHDG, + SetN2kMagneticHeading(n2kMsg,1,HDM, boatData->VAR->getDataWithDefault(N2kDoubleNA), boatData->DEV->getDataWithDefault(N2kDoubleNA) ); @@ -527,28 +527,29 @@ private: } void convertHDT(const SNMEA0183Msg &msg){ - double HDG=NMEA0183DoubleNA; - if (!NMEA0183ParseHDT_nc(msg, HDG)) + double HDT=NMEA0183DoubleNA; + if (!NMEA0183ParseHDT_nc(msg, HDT)) { LOG_DEBUG(GwLog::DEBUG, "failed to parse HDT %s", msg.line); return; } - if (! UD(HDG)) return; + if (! UD(HDT)) return; tN2kMsg n2kMsg; - SetN2kTrueHeading(n2kMsg,1,HDG); + SetN2kTrueHeading(n2kMsg,1,HDT); send(n2kMsg,msg.sourceId); } + void convertHDG(const SNMEA0183Msg &msg){ - double MHDG=NMEA0183DoubleNA; - double VAR=NMEA0183DoubleNA; + double HDM=NMEA0183DoubleNA; double DEV=NMEA0183DoubleNA; + double VAR=NMEA0183DoubleNA; if (msg.FieldCount() < 5) { LOG_DEBUG(GwLog::DEBUG, "failed to parse HDG %s", msg.line); return; } if (msg.FieldLen(0)>0){ - MHDG=formatDegToRad(atof(msg.Field(0))); + HDM=formatDegToRad(atof(msg.Field(0))); } else{ return; @@ -562,11 +563,11 @@ private: if (msg.Field(4)[0] == 'W') VAR=-VAR; } - if (! UD(MHDG)) return; + if (! UD(HDM)) return; UD(VAR); UD(DEV); tN2kMsg n2kMsg; - SetN2kMagneticHeading(n2kMsg,1,MHDG,DEV,VAR); + SetN2kMagneticHeading(n2kMsg,1,HDM,DEV,VAR); send(n2kMsg,msg.sourceId,"127250M"); } @@ -702,11 +703,11 @@ private: return; } tN2kMsg n2kMsg; - if (updateDouble(boatData->HDG,TrueHeading,msg.sourceId)){ + if (updateDouble(boatData->HDT,TrueHeading,msg.sourceId)){ SetN2kTrueHeading(n2kMsg,1,TrueHeading); send(n2kMsg,msg.sourceId); } - if(updateDouble(boatData->MHDG,MagneticHeading,msg.sourceId)){ + if(updateDouble(boatData->HDM,MagneticHeading,msg.sourceId)){ SetN2kMagneticHeading(n2kMsg,1,MagneticHeading, boatData->DEV->getDataWithDefault(N2kDoubleNA), boatData->VAR->getDataWithDefault(N2kDoubleNA) diff --git a/lib/nmea2kto0183/N2kDataToNMEA0183.cpp b/lib/nmea2kto0183/N2kDataToNMEA0183.cpp index b08ac7d..390d7f1 100644 --- a/lib/nmea2kto0183/N2kDataToNMEA0183.cpp +++ b/lib/nmea2kto0183/N2kDataToNMEA0183.cpp @@ -185,13 +185,13 @@ private: if (N2kIsNA(Variation)){ //no variation if (ref == N2khr_magnetic){ - updateDouble(boatData->MHDG,Heading); + updateDouble(boatData->HDM,Heading); if (NMEA0183SetHDM(NMEA0183Msg,Heading,talkerId)){ SendMessage(NMEA0183Msg); } } if (ref == N2khr_true){ - updateDouble(boatData->HDG,Heading); + updateDouble(boatData->HDT,Heading); if (NMEA0183SetHDT(NMEA0183Msg,Heading,talkerId)){ SendMessage(NMEA0183Msg); } @@ -206,8 +206,8 @@ private: if (ref == N2khr_true){ MagneticHeading=Heading-Variation; } - updateDouble(boatData->MHDG,MagneticHeading); - updateDouble(boatData->HDG,Heading); + updateDouble(boatData->HDM,MagneticHeading); + updateDouble(boatData->HDT,Heading); if (!N2kIsNA(MagneticHeading)){ if (NMEA0183SetHDG(NMEA0183Msg, MagneticHeading,_Deviation, Variation,talkerId)) @@ -252,8 +252,8 @@ private: tNMEA0183Msg NMEA0183Msg; updateDouble(boatData->STW, WaterReferenced); unsigned long now = millis(); - double MagneticHeading = (boatData->HDG->isValid(now) && boatData->VAR->isValid(now)) ? boatData->HDG->getData() + boatData->VAR->getData() : NMEA0183DoubleNA; - if (NMEA0183SetVHW(NMEA0183Msg, boatData->HDG->getDataWithDefault(NMEA0183DoubleNA), MagneticHeading, WaterReferenced,talkerId)) + double MagneticHeading = (boatData->HDT->isValid(now) && boatData->VAR->isValid(now)) ? boatData->HDT->getData() + boatData->VAR->getData() : NMEA0183DoubleNA; + if (NMEA0183SetVHW(NMEA0183Msg, boatData->HDT->getDataWithDefault(NMEA0183DoubleNA), MagneticHeading, WaterReferenced,talkerId)) { SendMessage(NMEA0183Msg); } @@ -492,8 +492,8 @@ private: updateDouble(boatData->TWS, WindSpeed); setMax(boatData->MaxTws, boatData->TWS); shouldSend = true; - if (boatData->HDG->isValid()) { - double twd = WindAngle+boatData->HDG->getData(); + if (boatData->HDT->isValid()) { + double twd = WindAngle+boatData->HDT->getData(); if (twd>2*M_PI) { twd-=2*M_PI; } updateDouble(boatData->TWD, twd); } From 26d1fd40eebfc08a77e1264f032131eff7d29d16 Mon Sep 17 00:00:00 2001 From: quantenschaum <woxpox@posteo.de> Date: Thu, 22 Aug 2024 15:17:16 +0200 Subject: [PATCH 03/58] adjusted formatting --- lib/boatData/GwBoatData.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/boatData/GwBoatData.h b/lib/boatData/GwBoatData.h index b4df0ec..f5af8e8 100644 --- a/lib/boatData/GwBoatData.h +++ b/lib/boatData/GwBoatData.h @@ -180,13 +180,13 @@ class GwBoatData{ GWBOATDATA(double,HDT,4000,formatCourse) // true heading GWBOATDATA(double,HDM,4000,formatCourse) // magnetic heading GWBOATDATA(double,STW,4000,formatKnots) // water speed - GWBOATDATA(double,VAR,4000,formatCourse) // variation - GWBOATDATA(double,DEV,4000,formatCourse) // deviation + GWBOATDATA(double,VAR,4000,formatWind) // variation + GWBOATDATA(double,DEV,4000,formatWind) // deviation GWBOATDATA(double,AWA,4000,formatWind) // apparent wind ANGLE GWBOATDATA(double,AWS,4000,formatKnots) // apparent wind speed GWBOATDATA(double,MaxAws,0,formatKnots) GWBOATDATA(double,TWD,4000,formatCourse) // true wind DIRECTION - GWBOATDATA(double,TWA,4000,formatCourse) // true wind ANGLE + GWBOATDATA(double,TWA,4000,formatWind) // true wind ANGLE GWBOATDATA(double,TWS,4000,formatKnots) // true wind speed GWBOATDATA(double,MaxTws,0,formatKnots) GWBOATDATA(double,ROT,4000,formatRot) // rate of turn From 0696b2acb1d0b84f73cb3dbc6cad1adc28120a90 Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Fri, 6 Sep 2024 11:32:41 +0200 Subject: [PATCH 04/58] update flastool to 3.3.3 - esp32s3 support --- tools/flashtool.pyz | Bin 412602 -> 467869 bytes tools/flashtool/esptool.py | 2111 +++++++++++++++++++++++++++--------- 2 files changed, 1590 insertions(+), 521 deletions(-) diff --git a/tools/flashtool.pyz b/tools/flashtool.pyz index e8ceb142b189fa276e2c6105ddd64bdd775cb6bd..a3f96072c08ddc78470948229227a964052639e1 100644 GIT binary patch delta 87969 zcmd4436vz)RUk@aFOsUI*3!FL>dvl4>MCVdWklrOa!bmL-1mJ`x66@{k(rSh8Ic(g zxk_@{24fq8EtBA|!5-VpXC1&c4X$I$A21&S<N09m`2(2wUI8<nL1X*if%(Rme_upy zRoz;Au>GlXx+^o{E%&~6_jli|kEv(g`v(tv=qC>tzV#n&yz{^xpFjG^pL$Q>_j2oZ zx{v?B54extkS;!Zbjm$<@6<rz$1W=p&9FPHKv#ISdNnKcSe5va;lz|8{nIy+&gm0> zcX0ar^ziimAam2_TL-643=dDAc;g+@XV(u-A0uv?Zd;B|AG2&t&l0Ai>2xA}#khW1 zR-_GfbNWZt^&3BA`JV^w{^pYpmwx!)oePv6KllUiZ~7+3=7mhsTio(<JWGk4k;K&- zN|qfeSBxZ1Shq-=B&YxAs9s*XXw(`;IffNvb}Lk61%<0|taQZ~NJh8FZG20Vws=}$ zrRmj2cMTGI<L@55>+k`~w&lh*-M@06QAmffz8z0AK}B}l*{m;?%w~*NjTYmcpNmGJ z*RHUVQLGsiuFc9uMKmhX$T*NVg%j#V+E}F(+F0R5rnR<~$t9DCbk^tHp@N|#8t+N< z3(p!H>I3gEoV|S6c!`xe7P3KBScN7o?fp(JzBA;d>o<PR_siHFm#!FnnWTlxOkW>3 zF&zXRo!%c<J2~$^dl@7>zO0*=-Wxb}LVrch`{}QyU-uus69%2Nr^p+>6lfeExtehY z;4#udRr|2L(-9@*%G#a<fG?<#{!YF7&w&E&jo2qzBYa4Zu&t!c9`USSo<Fj{BO5`k zKC;3iBDT`ThPtgju)_oE>H`NnaIQXZqA~2VCljL)CU?Oj%bsD}@Wj4n91rS7?iq=K zM*#E6;3U4kA4%NQkDTF(`pxmT+>F2FX8bMt@we>9-?ATn%YOVVd+=XJ_*-tm-?D=L zjju=6vHOf0Xm>R>Fft9UBX6qT_}~HCA*I2}th)7~1Mca2Vt+Y(XZBZaNbw8Uag7*O znj6!J*z|?$qth=Y9zI@WYetQLWy_8z?Fj4unCrb+|GCO&My8e*>F**rnG<A%78rIz zygY9WgKru|$#{#Gl;y_1B&G+ap4^We_S0>SAKmz)+(qoDKN)cEWTJ_}jwg}GPQR2V zj^{GI%ud=*ktE>&j`P#J%-*=GkihQVhx?5ui#_a~{UF}>OzEA6Di@6uERQkd1rBe} zijkIBqaYf2QK$p=%e@Zb4Yq0oRAC7eQRFpx&&6+M`KQokYHYiev1}k#H(EB=pHV;X zTI|e?H|#uPcyP{PGRxPtWu;fy;pCkvCu_2H`b)HhU__w`0!9>M<DkJY4Wq<%dmO+7 z8)rm4kQ}P8#(Af;>7RV))Qyvsj~tr#IZ0M78|$pHBeP5o<kV<~6KbN75!-FFBAjfL zL0X~_s;t5?@CgPDc{LdapAelaguuoUby46)2!9<lYGto48waA)l8tPi6>c+f0vb^L zJb&oKWOI8lQV1ky1Lm&L94|u;7H|SI1q1*Bt48zuR)uC-2r!AIt4O>e$l7$BV`g%A zWjN)M46iyJ*c7y1M!CZ>paB>}vPQaEl~`Fmh(@0N()G;?%Z^zD#DE474ye+Cu_v=e zQDCR>#+mb+wkrE4HP(PeS(<66ANaikC)Brp^1z#>m$^HSAc_Wpi=*CVF8yi{sQQ9x zf^=JEC64B2pA}kC<N>ENHrAyYgGlwt`sQ9)yV`djO(XR?9dbW?FCYaouC$!~(?@^s z4%m^XNLPUyFMjNysrO?SC!zJru!i!JR|xy{O{4Lm(ONOqc)AV?PiUXy#ttKmI?4_s z0I@h8fu}8YwA1NH9Z_bX8FWB|u)|dsJBY}3_6&eP!@I7du}QNPXp&ZXGLVM?5OTK* zeHxG#l<8}m4;}~oWkD*6gq@l5{`3pKXT)b;W(sWimRf14zeEqNUr{goHFo@g1qfG- z3@>koV(x%X)3-Yz@057t<n)JHuT?`|#ZF9rCvy7%*raK^2~z=}-d?*s(r63O7}H>Z z*fNMmSff`Q2t2Hzu`O2F>Ah!9s9XP+;kN1j%Rlta7Yql{NC(k&tFNa)1ODE>yXSUx z$gmy7xS@V-%W!IP85Th5G0HY8NuqQagpfk>KJ<ChNCRW+-RX<QoUq_4s$GSKnr?QI zcDq?)FO7(wUKw<zySFl=@#)3ri%v&oy)xOHXG##qA77ndMlK*sj2%f-L`LKRT%bi* zNMykP?}0_5eXP@*u)V##u0Hxz>>_(nXLU3-f=rW%u<I;vTMg_g=xOXN@tDh(%SM3( zo<s{g=WaBtaYGOl?G?NPP;@DhA=T8Uuw%v=2wP=U53qYzbV~f)G1!EI)7NvJBOp9z zNdSQ5tJCgVj;WvfUF^v8Z@8`LSErYz2Y%+*)Y}ZI+n>d5pFZ34901Che&SEhO&|R5 z{pRnG;52G_;yqEIsN6gM20JwUOlt%EJ_eMqt&z{pj2JgIr&#y+j9k{HPdt6+6mJhT zh<~yDn%h7!Xw>tLcVhReuek;U_5Ad<>FvCE`Y7+1_IYCZAK){3cDHa!T|123eF(K* zQ&V5T?!Vy_`X^8GB52!<&23<`xg^<G2a$f`w|+Q*WiA@CASu_G-nwyvL4vf`*@S)X zgL!Oh00F{ofVKcp2XYpKnb=e6BHD4J6M6#2&`8&yZHduLBBOEq^sxK<T^e<)E6Qjc z5SU*7k^85A@y4U(Cuv6E`m83}E*n?AY)fp12ftxM>vCBGX!H8?;ExmxTKA8v_vFe> zQ=#jin~-$8s%h)XMlkUudZ)_r^k_<d?f5kEri<$Jzc^sK@u@d`;=lx9hcrj04(2v$ zgLD|e!zXuuAZ2ZNfg%N(M>y^gSg4aeFvu(<M~%>0Q@ndk2@O+G-e!e9CyBx~=+6zL z#j}aTB)a2^=XX$h5UcC!XcHu^v$1KsdKIk8bt4NFhi-T5nOX<+s?mX_T!BMtj&Ix0 z;}yj9VeoC-hGZ3bj~EGd?6ig|@&iD2^>GbS_&}^pFZ}4q>5sqV?&-gOD>r@nJ%^_6 z|Iyp0Yd?N%`n9(nIixS<>+d->egE4EuXNqbZ-3wrV7R_EHUH!Z3Td_lMio(1c6hN4 zi;0W|fNN%JEm~A(GIR&Y(AnGRwRil3`p8$XJ1?%Pv?~b17;^4(&k0c;sKx0y_SE#~ zxjTHi($r)P*e;!(vQz2RYKZ<(M_;?6j9?ED0U7ldh`>2nM3$=&0Sbm&ed_y(i<5oy zwujO-*A}27)du=uX%2zZSPY~sxUMaL)_Z7DG=+xchAys=5Y^3QWF>7b#sx>k2+~UT z6|d@arz6J+1^w~HvNdXqET0`+jqYiWo^Q-X+S@~OK#@KMi-VS!#zy55g73-(K72Bb zZ@K8LntRKC-L$!}{m2{?L{anY+I$>5&*ik}^R_pUbw{In=K*R(Q>hCfcKe(prXS@m zoYua~b{@6cu1jLCvq7l8<24+=q-)nXWvsj>HxV-<>95&Qo!6h4zW*ow2xMXZ{9+Yh z=J`|$>btTwHT~q7$x_j7tQUZ9>nnS+Ak>Ygp4q&hNk}BFHMW|IL{Logb<j3+dwm<$ zrcEmw3w*Y#V5e3=ruCHCmUDe`b7ff$!;S%T>a))q&QD+SuDkZYv&W_YAA(^(R@Hhu zn9+04E(854$X3^a3E*(ZBY}Yp=<uT}8o)Cu&lZgxT4?|QvF$nN2=49mB`^}J^rokO z;@A!LC*O-5T~U%C^`_W6i+9eoCZgvX#dm%jJF$lykl{WCu{J&ZF1zuvkpqE>{2Y0k z88JM&u`v_XNQQ21Y6|%^@7kXJ`*)o`vuBvAD<Vx(?$fut`%{OXdghApsb|#GC$ZC@ z6>qzE>6+jN8dQKrAiyLyfMAh6k`=Zi8~fl8DpyqZyA9{F#!C%F>Bv{i=Bmhmc%DoA zZBSiiuxFYei)8c6Co{8x8L^?PnD>pm-B8;6rP<!zxc<|>fz1qqb(_Y)lx1mt-eiw1 zZP1wkL6OtHYCept!s!$5dDn^6g+=NRL<B#Ye(_!BGy?#lWH)Yq@84k47g~2t#S@38 zU%PQ|ZD}~D??_{p)XN#{&Ku|7_l<k+Uc#Zl1h&h0mky#G8PUtvH-UKzvw8aI-`Kcu z?Zato`srV%+922q;17a`MByqVGr=d28;IjkObZAZIcG@xBT0mQker|ug$0Q#@9=C5 zY&4M6G&sc@w+x_d8HvlrE$!d*13$4j{pyWF)A*<FKnC^UbrdsW)x_5hJUD&s4<4L; z=+vR<<&UgQpSp4Xbmt>?-nRa#5d5r9KX}8cz5D8oA5`(O;kE~^qR1PVEP9AZ2TcEe zb8Y(7cdbv4c5j>h%dehBmij$_#2$dBU;nGK)7L47r`^bz<Le#`aSad+SGIb2ntJb9 z>$<iAhysz129&xO#E7t72!++5r_b`Iuub*DXACCw@t?yEVQbUEZzM@rKA)iZR=|R< zXtlw2H&6fj@7;-KUWy2By}RFd^t-r4*t+_}*A84>*`3SZr=2-BUo$W(bmIKp?9q)E zKJq@p^y?ojm_TA!NSRzQ3VBx80Fy(r>IibhsGC0ivCW4yV{O()GvI(+J+mL6TFu=C za{QIc4^GxKPka3eu$BBIetj?5qL-`A_pBMQzuBrcK5Ii>aqVw4b3dQ~(w;huypEU# zB!b|kPIQOc>9djyCjXv}`}z^9pg*JAN>Av^wi5rl@CT>vL$Rj0BZN%r%`4OY`q9bv zCI~T`2lh?J4cTos2Ov=V;VcS3EF>Ayj*>8sU)*^3xyP{S<>xLOtU+XLntJZa9saa0 zmGi}^()8Kqs5{W(9xE`T>7PGmU3}xFM?dafw!D%3_?vJ4?hEYcwGTgV!~MzkVN>s? zE>9o(gU1elOqu@m??06P{z&TS+y5Z6kj|g~<e}+fANhVs==1B8*w%cy@3kOE{bAuG za4iZHUPZj4e*_&TT!mm!6-A2Bmm5F-xswLF+v`mujdRtwK^R~4Dx(Eonq8S>XeXCR z?|5^uSZSpV>6-rX7e0Fyb=^t(0=mMwYOK)UE1DpDk6Ukyzj)xLAocXkUkaVjJdV{c ze1q<@H$MNRKg6a_{Ppd3Xz~Ss!L@+ckp-~E$5bL>IIdY84}9g0qZ`XNpqMpd>`qXR zfHFdDU+ayH#S3V>iO`&*rr{9v#dk8nkUy(UdPsu-l<Vf*fINXPysSqURqT%p58U|J zUk9+;W^hiQ`0_*3w|x1$n*J1a-(BEE%oleb1m7cgr`WlZ(>wqA=rON5>)t_VEP<W= z;+NllQQsQli=YeZOxkY*{?=?bf^3V~;!W4Sa`6mWmxX|VmkY$u+d~>bOxs`ii({yh zwx#pl#>b|q|90{2Ic?~)2R8LrPTolTw>KPkti$PALatRi8%Uz+EfD)7?~qgEmDn*A z^X{a*8NWA)NMdMnPmLda#J7C9((CG?|BUhxKr56_T31N_s&n(kCtmo{J@=#aL1#aE zBVGus>8qc6;KEWOYhjgDWuSA3`oI5A?Cf28;<W1Xzl)t#Kleq$TAI?&U69R%{4{XK zSXsCP?llboN{Ej@SRVx&md&>3Cos@tq<N0$^Z`Oy5*#Qf{AgQs{{wbH{iCSi$kiEa z5KKfzEjOLkXi=U;n@c&NC4f_PX;W`M?T}~GZ+#BCeFFkeU?0!zW!TCQ93Y@*_v`>h zcU^iFL~5t^ALziYAfIVX<rstQV276F8z1=g0|zSps5=wfS*RUo_SAxyE;;cwoY&&R z+R}QsWSu?3SDuk}y&bXoY}St4{SbzoJ$7PWpi2iwed1~C2Y0`69DCBB1u5J~&kmK1 z?f{ecb`W6#Ji6rC$&@lwBEI8sXMCFEa!?wadwS$G+9Pg$p&hCdy7hqMNdezU`2FZ9 zK6K*RbM-Z+u-}}lAb<{aHSBGxsG@^i?WukBsdl)lJ$B&Q>@!Q{J6&D!J6~QEmUm<K zRj0A*ca<+1)0%++p$v9loLN`MeF8GpWWE;kZqrTVP(iS$%?a>n)=t&{M=Z8r9uy|n zfgm3ztIxj%yMKy*^w0#o)JD*atyyP?i9KQ@a2&Q~!GGH5`&#K+u!K|OtMmEiaBldh z#O$DLbK95$MTL&S5JCu%2LO{>zz0?&gr(J!GWLjip2luNpyDqEst&{)(<PXyTLO82 zx~rd;u_Ie-4dS>PwoT(>j~Pkprtx8*7R$cQXe~7y_0Hjku{-b6RzMSS`UsXy^~r~^ z_4E6m&ql`ekqPym9>Prb?ICEy0-NgFAI2V<Xr1@G%%FZZKhA6Wh{PoXCn}u4?(}IM zBF<noz*Zu;O(L}lA+M(wPc(Z1)a}zC+qEV-PoP226NrRNAUL`z8_*j_SXf~K^aK(X zR%8Hr0*MIZlBv)WNGh-_pFE>Wzg0*t8&?dQnZ+y}04UUZW$cvt_+jj<`Y9iFRDJ#u zc0Q-mA^-{CX^ZiqyN)JDKl+C6?}1ohL9FV%8`#OCD?>w0&hDcd*qg9ZXatlOp+!Ts zym-NzCb#kyb$1i{<AeQ&s@rP!8SL1JMc>QI`8L&eJdGVwj|8yGyMOZt_G9;)yh$i( z;Vt#+$FT=?-}ET<jNt^D&gX})wVowJXi`!AVi`Mj+G^EydgU#-e0#t~t*IYuV<*+~ z<%5TI&p(FUaa-YE!FR8m?M}bg2B^4%Suv~nX&F1Oo*}R!9xd@nM+pSSh81K8WIFAD zCarF2TsLD4+`o_O7MrDU-MI$0Gw%mfU|BFggF{rW9wD$Zg<H(E(oLU^oX@AL5VZDi z;<Y-qzT~qPi^17j48Gb?AAKf4aK{3^8F6m_oXDQvghhaE`*7VOVYbx2kYP{J>X~ja z>3kLR2??hzS#XQ`4jMbIdL-=fvj1Z*+n)r9fMnkO<?qN6-9{_F0?p$uKiV9ceVeI8 zT1agpk6ko+qLH1vFP#a2u8)P{iL{!Ou;Z&#^N2Q+PU^R&n#DpReoLz*KXx7?)dx1Q zGm{MI4_aqwVrUo94}qfuDxII<eLPLx)BvSs%^U1_t&6XP+|do@rA}PYhT5XtGY@&p z^`-hd0Nj%cVe#FRVJNb)v8mP{!Op#6x%5+$Js1EuOKT7<6<UY52xngvwu%k~{V0sI zf3?tB?P*|KrpKW{(2us_dmCvPbGlK7TD<hi!U^ciPTmj9=9+qM5IeCKk?T57p>ef+ zm~X?&BACpi-LaQKP-}1t1W)e4P;Uhos_*t=XCFcQGY4S<?EIT?(dV7va`zsXudk_( zg|PdN-)d*nPlT{@_bs(6LKr0xsvEh3j+4ebu@4mlr1mau%&$`YzQxyB^p+4)G|yEe z{p22^^1!|%oCaxIaL?Xu*VlHx5W;@(i2C_28tzcvp2NO$5Dwf}wV%iSWcucB=G1lp z`}^r@-*(sZ{Et2`{g=1grYc43arGBC>~7439EFPp*LC&mX>9$$nRW6a+OylluIzrf zh`k3ph&ymx{VI>0Q~$Rzb}56FV?|xgG|$!fv$<>WVu}^0W!8J%(njrm{3`Y?gL+*A zqeOfH>l|j-8ewJBzj*>nV;0rv#-{4gS7Udm|F;{<-(|;f+)0o+m8-EFc2O;RFtbXZ z0`dI9b!5|sSZ=rbYAkH{eroH5K3(oH($<eHwZ-J+<@;P)IF<?wG790?5}!p6W`-9% zScw~4G#0>brr`umsOpD}nlM~C1sn@5!hoL0MmWzBAx8wn4*0+#I0ce%o+-A_s23qq z5rRs3^nNCX(a~<7*d~_QcNdM(9@Bz!PB`+1|B&x(>_g52WU2I#y8#_q9Qa3&Z#^Gr zqD87TuSqlNA;p8Pv&$jps(b+nqP2|$#+n{=Xi<GVbz-41Dtv3c=(=B}XLJzSRLfSb z)n;hBX3ZA!&GHCd#H+cugk;4`mgvEb{sJw5`h_j*<aF}8hwhzk+oIF-tFe=I-Lpk_ z&5&KZUT!$D{q~KDoT_~mnr#8~cWj}7k))X<FKVGJtv@iOfMa#@?^fh{vxT-Wn7%U; z?dB~+`?f;lLQjqPusc?*n9$2wF>_mVpU1gpGDHaKqdx4RC8JU=&!`LgXz#x1R)|Ik zXwSY2B!l4)1w7$g5XHijol`udooJ*mr&^747Ojv?coDNL9fp@TdFO6AGI^iIw!i0= z6@fgyhpL<F#)piWl-ovkE_UMXm=7F34HIxQZ|MV`-i1hmaGUyo1-pH>l)}shU%6&n z|7Y+c-`BvNQs2m9_o;6v!=dR#1>5?!i0^t6%e{!By9=rsH{5%ZO4j^agVnnG9v=JY zL;toE;Va;;TPQ-!CthgTTY0VDf0<ajg{1hd$;7?F<X^?b8MUWi{_i<yzhWmz3lSdK zE%mUUIegnJ|3$t0DE9E}K~lSEL8#9+uu}#CNA%ssRR7c1gLg1AX=fnk{<^wV0<*8r zV;9a6Zm<V60r-+E;5jgvIWR97PqsR5j@FY_{n<*K?h<i}Xgxvd?PjsMS@?PpFNzz# z<3Q~z3m9SezalEMVrbsXUn{YRmuJ}(#lJ@%?WN+CAzy(eno9vq6Q!}B`Yu1_ngo%1 zgls7EvrrIVrz}jp<(w;8Qg8o?Vwbw#znEiYtF1pIqiC=C+DEY4zB5X{<4#Z1XLjP` zHeuVgs@HlDL_#rggN0O|s$oB32$JgS2{^{NRL5$V`hO0vEEe4Tcocj1fEsuT`_)s+ z#5Fxnb<Q$BR>U4eoj=up--Px1qp;oIvBF+kTVo*1A?ud~C{q3AQ`jcNlm4WEoiq@n z3OxQOSa!eK#2!Cn1%AfWKW|~@A0Ulzj-lK7@PPoE8Ql#+8SDH=bw7=*pRoIFgax<z zA>KW7Fjj)b?X~ObgvZubHR4K47iRex9^07A-QSlwy0hQn=+1Jhqr2I&m9YIK!uJ1< zFhGYkFB3f`_8WsQ6$d2Yj6-u49Ei3uuxFp!z|LTl&WY5&DNfv4$bwa4aj$i};;Lzu z@ycT8q4bwp48pE2#=hw5QrhU&_R5$qA84J&%R$7U^wAxV3RkHa;cNNSx4gQDj`OrI zBRKHrn0g_qxSIOyOW2ud7p~b%&K)|s_Y8abJN(Jxnv@J>cF-Lo5SsdN$(NtP9y_FI zUQO|;k3WqSzxPI?uDVu2BrmUKNY|VoUut7l)Xd8dxzJoMxvwijz5>lPXB$m(z5DxU zcpbkL6YRd@8O(nBeHow>{eGdYMKTukL(gD8sJ`~C2i4v8zYcR4Al&xs8{q)=Yj40# z@4n&9*e@827ILO+_V=FaAT-3nhI`#x5B?My>Sr<ai8o_Q+YUxOxoCt&KQmKlH;<}b z_#td_)|OQF4R~1ZrhZ_8otl6C>Km{#$0&0DNR;})XRwD1pl$Yz0n!U~hhNb1)b8e6 zvHxVafNov%DjU-JlUo~`*R|_Gm$j(YHrVs%!piPFZ^Je)a1xVm!|um2>Z{+5eN27+ zZP*d@`L_e2v%CCi;%(U31)@7|$1We%->C7oVGk^@f)`ub69gW7IlFuMS?sPOr{^|` z#-klb5mLk*nH#ga#-G7HvxXVfZ~h#%t-k)ZAk_J~U%=LO13!;h?@{^pV7H%y>wG(K z2W+PUF*U7^=1Z#|K8l?<6M~yk^PA6?_23+2>i77n`u;B+c;vh>z$!A_q2-}M1j-AC z$P3BAZC!R-yF#SC>iyX1d#MIwi=+IQSS$y380xHg{`jF&SK&<isb?<0jhh<X<F(X( zh%DNz5zqEKNniKrA>Dl^ns9r4>7{$WIeq`PkMI82N3c6i?c#ro{oH}0!QeVNk6QPv z!#SpP_ltjm{S~HmzKI>bxPuZYKwK>&XxbXJ=^@Il(BAI;_Mc+-gVXI(x34XiPEB1i z98;hDVZ&kdA721J|Ksn)ZdafD6n4i2xXOocL>Y8-NELrbHy@WhS#>*NIE8ZXUWFb) zj?m1)Txnw5GH$%gDARZFC)HPf6}$bO%KEiot%fku$J_nF*Ra1hqz=D{-FX*`4Mi{b zs{Eu44kr-CjycA&=l1IhA%7F*%ifOb>aYC+_G-+zd)Gf=g9C8s;5f<vRiF7LcH1Fs z#QCF3t4p^WTc6Enf{GXTb+ifU`@V_2>+Xg5hAxo8QT^AyjcwniT_%Mu%%`!7XSB0D zeMvPy(d`56gWCNDcK;CoQQuX<t`ZMG=;cd)hOHmg66zjP@n41OlpB8tN%&_!gZ<QQ z{4cS)u)DSBEo{z)ysceVD*qSkjr$+&e(t|u$ph+re}(<k_C>G{+ad&?owM7^=&}PO zcA>H!D7PLiV!}lj*yVZ~Zq2J-`~t{1?z7mDQv%(F)Q+o0AXs!3w=IdN&n3u-Jgx5h zJ$B?iwC`Fc4Ui|DSFTU#A3LsAzl>da=YIt;^3R7M-Q|D$CHxosTNw1UFMxCUroY8L ztG@3AY_R*|{{#DkVfWAe9{W@56yz4pwv(`3N38ZF<fszYr`>bM)O-FHcJJEqt-Afc zu+d8El}m0Krf?M(xKaJxf55^Atyc(}de1*%KYC~lZG-x?Z($Ga7XJzR42CueS6}-r z?Ao2cfnbMg+mum1{B7((5MrN(U%RKjjorYGK)<=9*E%7$;&tD`iZek?taP7C{;z*) zA=1EK|Mo)gk$VO>K9lr&22lU*Ti69O#ohmm8IceKI0>*W(Trv7hQw06-&(RkY99#3 zl?XAcEqbC~K!%ki>=q}aL0U+M-Few)x7jRq*teQ70||<=n`*QX@_M+cCeF0Z%UfgG z;7d1CH=2+Tb4NpgI1&Xg*L*!GZN(89unMQt|MV|dP|L;3_^6$1De2n@#Y1r8C+#jl zM@R<HFJms=`r-A;+nSDgGMxzQ%-f(myN&sm9rVf*%4RMb7mpS*%$^-Z(=G<y{LT9e z?^!zy1m}zTQBj`lJ)`gb$2G(Hop	w(ZC~9A-eq^v|jI8N<2L=sFQ(#lpdk2<xly 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 delta 8928 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 diff --git a/tools/flashtool/esptool.py b/tools/flashtool/esptool.py index 01176af..d1d62b4 100755 --- a/tools/flashtool/esptool.py +++ b/tools/flashtool/esptool.py @@ -1,20 +1,8 @@ #!/usr/bin/env python # -# ESP8266 & ESP32 family ROM Bootloader Utility -# Copyright (C) 2014-2021 Fredrik Ahlberg, Angus Gratton, Espressif Systems (Shanghai) CO LTD, other contributors as noted. -# https://github.com/espressif/esptool +# SPDX-FileCopyrightText: 2014-2022 Fredrik Ahlberg, Angus Gratton, Espressif Systems (Shanghai) CO LTD, other contributors as noted. # -# This program is free software; you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free Software -# Foundation; either version 2 of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License along with -# this program; if not, write to the Free Software Foundation, Inc., 51 Franklin -# Street, Fifth Floor, Boston, MA 02110-1301 USA. +# SPDX-License-Identifier: GPL-2.0-or-later from __future__ import division, print_function @@ -27,6 +15,7 @@ import inspect import io import itertools import os +import re import shlex import string import struct @@ -68,7 +57,7 @@ except Exception: raise -__version__ = "3.1" +__version__ = "3.3.3" MAX_UINT32 = 0xffffffff MAX_UINT24 = 0xffffff @@ -84,6 +73,9 @@ ERASE_WRITE_TIMEOUT_PER_MB = 40 # timeout (per megabyte) for erasing and w MEM_END_ROM_TIMEOUT = 0.05 # special short timeout for ESP_MEM_END, as it may never respond DEFAULT_SERIAL_WRITE_TIMEOUT = 10 # timeout for serial port write DEFAULT_CONNECT_ATTEMPTS = 7 # default number of times to try connection +WRITE_BLOCK_ATTEMPTS = 3 # number of times to try writing a data block + +SUPPORTED_CHIPS = ['esp8266', 'esp32', 'esp32s2', 'esp32s3beta2', 'esp32s3', 'esp32c3', 'esp32c6beta', 'esp32h2beta1', 'esp32h2beta2', 'esp32c2'] def timeout_per_mb(seconds_per_mb, size_bytes): @@ -100,9 +92,12 @@ def _chip_to_rom_loader(chip): 'esp32': ESP32ROM, 'esp32s2': ESP32S2ROM, 'esp32s3beta2': ESP32S3BETA2ROM, - 'esp32s3beta3': ESP32S3BETA3ROM, + 'esp32s3': ESP32S3ROM, 'esp32c3': ESP32C3ROM, 'esp32c6beta': ESP32C6BETAROM, + 'esp32h2beta1': ESP32H2BETA1ROM, + 'esp32h2beta2': ESP32H2BETA2ROM, + 'esp32c2': ESP32C2ROM, }[chip] @@ -124,13 +119,37 @@ def get_default_connected_device(serial_list, port, connect_attempts, initial_ba if port is not None: raise print("%s failed to connect: %s" % (each_port, err)) + if _esp and _esp._port: + _esp._port.close() _esp = None return _esp -DETECTED_FLASH_SIZES = {0x12: '256KB', 0x13: '512KB', 0x14: '1MB', - 0x15: '2MB', 0x16: '4MB', 0x17: '8MB', - 0x18: '16MB', 0x19: '32MB', 0x1a: '64MB'} +DETECTED_FLASH_SIZES = { + 0x12: "256KB", + 0x13: "512KB", + 0x14: "1MB", + 0x15: "2MB", + 0x16: "4MB", + 0x17: "8MB", + 0x18: "16MB", + 0x19: "32MB", + 0x1A: "64MB", + 0x1B: "128MB", + 0x1C: "256MB", + 0x20: "64MB", + 0x21: "128MB", + 0x22: "256MB", + 0x32: "256KB", + 0x33: "512KB", + 0x34: "1MB", + 0x35: "2MB", + 0x36: "4MB", + 0x37: "8MB", + 0x38: "16MB", + 0x39: "32MB", + 0x3A: "64MB", +} def check_supported_function(func, check_func): @@ -139,8 +158,8 @@ def check_supported_function(func, check_func): bootloader function to check if it's supported. This is used to capture the multidimensional differences in - functionality between the ESP8266 & ESP32/32S2/32S3/32C3 ROM loaders, and the - software stub that runs on both. Not possible to do this cleanly + functionality between the ESP8266 & ESP32 (and later chips) ROM loaders, and the + software stub that runs on these. Not possible to do this cleanly via inheritance alone. """ def inner(*args, **kwargs): @@ -152,16 +171,26 @@ def check_supported_function(func, check_func): return inner +def esp8266_function_only(func): + """ Attribute for a function only supported on ESP8266 """ + return check_supported_function(func, lambda o: o.CHIP_NAME == "ESP8266") + + def stub_function_only(func): """ Attribute for a function only supported in the software stub loader """ return check_supported_function(func, lambda o: o.IS_STUB) def stub_and_esp32_function_only(func): - """ Attribute for a function only supported by software stubs or ESP32/32S2/32S3/32C3 ROM """ + """ Attribute for a function only supported by software stubs or ESP32 and later chips ROM """ return check_supported_function(func, lambda o: o.IS_STUB or isinstance(o, ESP32ROM)) +def esp32s3_or_newer_function_only(func): + """ Attribute for a function only supported by ESP32S3 and later chips ROM """ + return check_supported_function(func, lambda o: isinstance(o, ESP32S3ROM) or isinstance(o, ESP32C3ROM)) + + PYTHON2 = sys.version_info[0] < 3 # True if on pre-Python 3 # Function to return nth byte of a bitstring @@ -204,14 +233,9 @@ def _mask_to_shift(mask): return shift -def esp8266_function_only(func): - """ Attribute for a function only supported on ESP8266 """ - return check_supported_function(func, lambda o: o.CHIP_NAME == "ESP8266") - - class ESPLoader(object): """ Base class providing access to ESP ROM & software stub bootloaders. - Subclasses provide ESP8266 & ESP32 specific functionality. + Subclasses provide ESP8266 & ESP32 Family specific functionality. Don't instantiate this base class directly, either instantiate a subclass or call ESPLoader.detect_chip() which will interrogate the chip and return the @@ -221,8 +245,12 @@ class ESPLoader(object): CHIP_NAME = "Espressif device" IS_STUB = False + FPGA_SLOW_BOOT = False + DEFAULT_PORT = "/dev/ttyUSB0" + USES_RFC2217 = False + # Commands supported by ESP8266 ROM bootloader ESP_FLASH_BEGIN = 0x02 ESP_FLASH_DATA = 0x03 @@ -234,7 +262,7 @@ class ESPLoader(object): ESP_WRITE_REG = 0x09 ESP_READ_REG = 0x0a - # Some comands supported by ESP32 ROM bootloader (or -8266 w/ stub) + # Some comands supported by ESP32 and later chips ROM bootloader (or -8266 w/ stub) ESP_SPI_SET_PARAMS = 0x0B ESP_SPI_ATTACH = 0x0D ESP_READ_FLASH_SLOW = 0x0e # ROM only, much slower than the stub flash read @@ -244,7 +272,7 @@ class ESPLoader(object): ESP_FLASH_DEFL_END = 0x12 ESP_SPI_FLASH_MD5 = 0x13 - # Commands supported by ESP32-S2/S3/C3/C6 ROM bootloader only + # Commands supported by ESP32-S2 and later chips ROM bootloader only ESP_GET_SECURITY_INFO = 0x14 # Some commands supported by stub only @@ -295,6 +323,9 @@ class ESPLoader(object): # Device PIDs USB_JTAG_SERIAL_PID = 0x1001 + # Chip IDs that are no longer supported by esptool + UNSUPPORTED_CHIPS = {6: "ESP32-S3(beta 3)"} + def __init__(self, port=DEFAULT_PORT, baud=ESP_ROM_BAUD, trace_enabled=False): """Base constructor for ESPLoader bootloader interaction @@ -308,6 +339,7 @@ class ESPLoader(object): """ self.secure_download_mode = False # flag is set to True if esptool detects the ROM is in Secure Download Mode + self.stub_is_disabled = False # flag is set to True if esptool detects conditions which require the stub to be disabled if isinstance(port, basestring): self._port = serial.serial_for_url(port) @@ -343,36 +375,62 @@ class ESPLoader(object): connect_attempts=DEFAULT_CONNECT_ATTEMPTS): """ Use serial access to detect the chip type. - We use the UART's datecode register for this, it's mapped at - the same address on ESP8266 & ESP32 so we can use one - memory read and compare to the datecode register for each chip - type. + First, get_security_info command is sent to detect the ID of the chip + (supported only by ESP32-C3 and later, works even in the Secure Download Mode). + If this fails, we reconnect and fall-back to reading the magic number. + It's mapped at a specific ROM address and has a different value on each chip model. + This way we can use one memory read and compare it to the magic number for each chip type. This routine automatically performs ESPLoader.connect() (passing connect_mode parameter) as part of querying the chip. """ + inst = None detect_port = ESPLoader(port, baud, trace_enabled=trace_enabled) + if detect_port.serial_port.startswith("rfc2217:"): + detect_port.USES_RFC2217 = True detect_port.connect(connect_mode, connect_attempts, detecting=True) try: print('Detecting chip type...', end='') - sys.stdout.flush() - chip_magic_value = detect_port.read_reg(ESPLoader.CHIP_DETECT_MAGIC_REG_ADDR) + res = detect_port.check_command('get security info', ESPLoader.ESP_GET_SECURITY_INFO, b'') + res = struct.unpack("<IBBBBBBBBI", res[:16]) # 4b flags, 1b flash_crypt_cnt, 7*1b key_purposes, 4b chip_id + chip_id = res[9] # 2/4 status bytes invariant - for cls in [ESP8266ROM, ESP32ROM, ESP32S2ROM, ESP32S3BETA2ROM, ESP32S3BETA3ROM, ESP32C3ROM, ESP32C6BETAROM]: - if chip_magic_value in cls.CHIP_DETECT_MAGIC_VALUE: - # don't connect a second time + for cls in [ESP32S3BETA2ROM, ESP32S3ROM, ESP32C3ROM, ESP32C6BETAROM, ESP32H2BETA1ROM, ESP32C2ROM, ESP32H2BETA2ROM]: + if chip_id == cls.IMAGE_CHIP_ID: inst = cls(detect_port._port, baud, trace_enabled=trace_enabled) inst._post_connect() - print(' %s' % inst.CHIP_NAME, end='') - if detect_port.sync_stub_detected: - inst = inst.STUB_CLASS(inst) - inst.sync_stub_detected = True - return inst - except UnsupportedCommandError: - raise FatalError("Unsupported Command Error received. Probably this means Secure Download Mode is enabled, " - "autodetection will not work. Need to manually specify the chip.") + try: + inst.read_reg(ESPLoader.CHIP_DETECT_MAGIC_REG_ADDR) # Dummy read to check Secure Download mode + except UnsupportedCommandError: + inst.secure_download_mode = True + except (UnsupportedCommandError, struct.error, FatalError) as e: + # UnsupportedCmdErr: ESP8266/ESP32 ROM | struct.err: ESP32-S2 | FatalErr: ESP8266/ESP32 STUB + print(" Unsupported detection protocol, switching and trying again...") + try: + # ESP32/ESP8266 are reset after an unsupported command, need to connect again (not needed on ESP32-S2) + if not isinstance(e, struct.error): + detect_port.connect(connect_mode, connect_attempts, detecting=True, warnings=False) + print('Detecting chip type...', end='') + sys.stdout.flush() + chip_magic_value = detect_port.read_reg(ESPLoader.CHIP_DETECT_MAGIC_REG_ADDR) + + for cls in [ESP8266ROM, ESP32ROM, ESP32S2ROM, ESP32S3BETA2ROM, ESP32S3ROM, + ESP32C3ROM, ESP32C6BETAROM, ESP32H2BETA1ROM, ESP32C2ROM, ESP32H2BETA2ROM]: + if chip_magic_value in cls.CHIP_DETECT_MAGIC_VALUE: + inst = cls(detect_port._port, baud, trace_enabled=trace_enabled) + inst._post_connect() + inst.check_chip_id() + except UnsupportedCommandError: + raise FatalError("Unsupported Command Error received. Probably this means Secure Download Mode is enabled, " + "autodetection will not work. Need to manually specify the chip.") finally: - print('') # end line + if inst is not None: + print(' %s' % inst.CHIP_NAME, end='') + if detect_port.sync_stub_detected: + inst = inst.STUB_CLASS(inst) + inst.sync_stub_detected = True + print('') # end line + return inst raise FatalError("Unexpected CHIP magic value 0x%08x. Failed to autodetect chip type." % (chip_magic_value)) """ Read a SLIP packet from the serial port """ @@ -512,22 +570,24 @@ class ESPLoader(object): active_port = self._port.port # Pyserial only identifies regular ports, URL handlers are not supported - if not active_port.startswith(("COM", "/dev/")): + if not active_port.lower().startswith(("com", "/dev/")): print("\nDevice PID identification is only supported on COM and /dev/ serial ports.") return # Return the real path if the active port is a symlink if active_port.startswith("/dev/") and os.path.islink(active_port): active_port = os.path.realpath(active_port) + # The "cu" (call-up) device has to be used for outgoing communication on MacOS + if sys.platform == "darwin" and "tty" in active_port: + active_port = [active_port, active_port.replace("tty", "cu")] ports = list_ports.comports() for p in ports: - if p.device == active_port: + if p.device in active_port: return p.pid print("\nFailed to get PID of a device on {}, using standard reset sequence.".format(active_port)) - def bootloader_reset(self, esp32r0_delay=False, usb_jtag_serial=False): - """ Issue a reset-to-bootloader, with esp32r0 workaround options - and USB-JTAG-Serial custom reset sequence option + def bootloader_reset(self, usb_jtag_serial=False, extra_delay=False): + """ Issue a reset-to-bootloader, with USB-JTAG-Serial custom reset sequence option """ # RTS = either CH_PD/EN or nRESET (both active low = chip in reset) # DTR = GPIO0 (active low = boot to flasher) @@ -550,36 +610,23 @@ class ESPLoader(object): self._setDTR(False) self._setRTS(False) else: + # This fpga delay is for Espressif internal use + fpga_delay = True if self.FPGA_SLOW_BOOT and os.environ.get("ESPTOOL_ENV_FPGA", "").strip() == "1" else False + delay = 7 if fpga_delay else 0.5 if extra_delay else 0.05 # 0.5 needed for ESP32 rev0 and rev1 + self._setDTR(False) # IO0=HIGH self._setRTS(True) # EN=LOW, chip in reset time.sleep(0.1) - if esp32r0_delay: - # Some chips are more likely to trigger the esp32r0 - # watchdog reset silicon bug if they're held with EN=LOW - # for a longer period - time.sleep(1.2) self._setDTR(True) # IO0=LOW self._setRTS(False) # EN=HIGH, chip out of reset - if esp32r0_delay: - # Sleep longer after reset. - # This workaround only works on revision 0 ESP32 chips, - # it exploits a silicon bug spurious watchdog reset. - time.sleep(0.4) # allow watchdog reset to occur - time.sleep(0.05) + time.sleep(delay) self._setDTR(False) # IO0=HIGH, done - def _connect_attempt(self, mode='default_reset', esp32r0_delay=False, usb_jtag_serial=False): - """ A single connection attempt, with esp32r0 workaround options """ - # esp32r0_delay is a workaround for bugs with the most common auto reset - # circuit and Windows, if the EN pin on the dev board does not have - # enough capacitance. - # - # Newer dev boards shouldn't have this problem (higher value capacitor - # on the EN pin), and ESP32 revision 1 can't use this workaround as it - # relies on a silicon bug. - # - # Details: https://github.com/espressif/esptool/issues/136 + def _connect_attempt(self, mode='default_reset', usb_jtag_serial=False, extra_delay=False): + """ A single connection attempt """ last_error = None + boot_log_detected = False + download_mode = False # If we're doing no_sync, we're likely communicating as a pass through # with an intermediate device to the ESP32 @@ -587,7 +634,18 @@ class ESPLoader(object): return last_error if mode != 'no_reset': - self.bootloader_reset(esp32r0_delay, usb_jtag_serial) + if not self.USES_RFC2217: # Might block on rfc2217 ports + self._port.reset_input_buffer() # Empty serial buffer to isolate boot log + self.bootloader_reset(usb_jtag_serial, extra_delay) + + # Detect the ROM boot log and check actual boot mode (ESP32 and later only) + waiting = self._port.inWaiting() + read_bytes = self._port.read(waiting) + data = re.search(b'boot:(0x[0-9a-fA-F]+)(.*waiting for download)?', read_bytes, re.DOTALL) + if data is not None: + boot_log_detected = True + boot_mode = data.group(1) + download_mode = data.group(2) is not None for _ in range(5): try: @@ -596,13 +654,15 @@ class ESPLoader(object): self.sync() return None except FatalError as e: - if esp32r0_delay: - print('_', end='') - else: - print('.', end='') + print('.', end='') sys.stdout.flush() time.sleep(0.05) last_error = e + + if boot_log_detected: + last_error = FatalError("Wrong boot mode detected ({})! The chip needs to be in download mode.".format(boot_mode.decode("utf-8"))) + if download_mode: + last_error = FatalError("Download mode successfully detected, but getting no sync reply: The serial TX path seems to be down.") return last_error def get_memory_region(self, name): @@ -613,9 +673,9 @@ class ESPLoader(object): except IndexError: return None - def connect(self, mode='default_reset', attempts=DEFAULT_CONNECT_ATTEMPTS, detecting=False): + def connect(self, mode='default_reset', attempts=DEFAULT_CONNECT_ATTEMPTS, detecting=False, warnings=True): """ Try connecting repeatedly until successful, or giving up """ - if mode in ['no_reset', 'no_reset_no_sync']: + if warnings and mode in ['no_reset', 'no_reset_no_sync']: print('WARNING: Pre-connection option "{}" was selected.'.format(mode), 'Connection may fail if the chip is not in bootloader or flasher stub mode.') print('Connecting...', end='') @@ -625,18 +685,17 @@ class ESPLoader(object): usb_jtag_serial = (mode == 'usb_reset') or (self._get_pid() == self.USB_JTAG_SERIAL_PID) try: - for _ in range(attempts) if attempts > 0 else itertools.count(): - last_error = self._connect_attempt(mode=mode, esp32r0_delay=False, usb_jtag_serial=usb_jtag_serial) - if last_error is None: - break - last_error = self._connect_attempt(mode=mode, esp32r0_delay=True, usb_jtag_serial=usb_jtag_serial) + for _, extra_delay in zip(range(attempts) if attempts > 0 else itertools.count(), itertools.cycle((False, True))): + last_error = self._connect_attempt(mode=mode, usb_jtag_serial=usb_jtag_serial, extra_delay=extra_delay) if last_error is None: break finally: print('') # end 'Connecting...' line if last_error is not None: - raise FatalError('Failed to connect to %s: %s' % (self.CHIP_NAME, last_error)) + raise FatalError('Failed to connect to {}: {}' + '\nFor troubleshooting steps visit: ' + 'https://docs.espressif.com/projects/esptool/en/latest/troubleshooting.html'.format(self.CHIP_NAME, last_error)) if not detecting: try: @@ -644,11 +703,12 @@ class ESPLoader(object): chip_magic_value = self.read_reg(ESPLoader.CHIP_DETECT_MAGIC_REG_ADDR) if chip_magic_value not in self.CHIP_DETECT_MAGIC_VALUE: actually = None - for cls in [ESP8266ROM, ESP32ROM, ESP32S2ROM, ESP32S3BETA2ROM, ESP32S3BETA3ROM, ESP32C3ROM]: + for cls in [ESP8266ROM, ESP32ROM, ESP32S2ROM, ESP32S3BETA2ROM, ESP32S3ROM, + ESP32C3ROM, ESP32H2BETA1ROM, ESP32H2BETA2ROM, ESP32C2ROM, ESP32C6BETAROM]: if chip_magic_value in cls.CHIP_DETECT_MAGIC_VALUE: actually = cls break - if actually is None: + if warnings and actually is None: print(("WARNING: This chip doesn't appear to be a %s (chip magic value 0x%08x). " "Probably it is unsupported by this version of esptool.") % (self.CHIP_NAME, chip_magic_value)) else: @@ -656,6 +716,7 @@ class ESPLoader(object): except UnsupportedCommandError: self.secure_download_mode = True self._post_connect() + self.check_chip_id() def _post_connect(self): """ @@ -751,7 +812,8 @@ class ESPLoader(object): timeout = timeout_per_mb(ERASE_REGION_TIMEOUT_PER_MB, size) # ROM performs the erase up front params = struct.pack('<IIII', erase_size, num_blocks, self.FLASH_WRITE_SIZE, offset) - if isinstance(self, (ESP32S2ROM, ESP32S3BETA2ROM, ESP32S3BETA3ROM, ESP32C3ROM, ESP32C6BETAROM)) and not self.IS_STUB: + if isinstance(self, (ESP32S2ROM, ESP32S3BETA2ROM, ESP32S3ROM, ESP32C3ROM, + ESP32C6BETAROM, ESP32H2BETA1ROM, ESP32C2ROM, ESP32H2BETA2ROM)) and not self.IS_STUB: params += struct.pack('<I', 1 if begin_rom_encrypted else 0) self.check_command("enter Flash download mode", self.ESP_FLASH_BEGIN, params, timeout=timeout) @@ -759,26 +821,52 @@ class ESPLoader(object): print("Took %.2fs to erase flash block" % (time.time() - t)) return num_blocks - """ Write block to flash """ def flash_block(self, data, seq, timeout=DEFAULT_TIMEOUT): - self.check_command("write to target Flash after seq %d" % seq, - self.ESP_FLASH_DATA, - struct.pack('<IIII', len(data), seq, 0, 0) + data, - self.checksum(data), - timeout=timeout) + """Write block to flash, retry if fail""" + for attempts_left in range(WRITE_BLOCK_ATTEMPTS - 1, -1, -1): + try: + self.check_command( + "write to target Flash after seq %d" % seq, + self.ESP_FLASH_DATA, + struct.pack("<IIII", len(data), seq, 0, 0) + data, + self.checksum(data), + timeout=timeout, + ) + break + except FatalError: + if attempts_left: + self.trace( + "Block write failed, " + "retrying with {} attempts left".format(attempts_left) + ) + else: + raise - """ Encrypt before writing to flash """ def flash_encrypt_block(self, data, seq, timeout=DEFAULT_TIMEOUT): - if isinstance(self, (ESP32S2ROM, ESP32C3ROM)) and not self.IS_STUB: + """Encrypt, write block to flash, retry if fail""" + if isinstance(self, (ESP32S2ROM, ESP32C3ROM, ESP32S3ROM, ESP32H2BETA1ROM, ESP32C2ROM, ESP32H2BETA2ROM)) and not self.IS_STUB: # ROM support performs the encrypted writes via the normal write command, # triggered by flash_begin(begin_rom_encrypted=True) return self.flash_block(data, seq, timeout) - self.check_command("Write encrypted to target Flash after seq %d" % seq, - self.ESP_FLASH_ENCRYPT_DATA, - struct.pack('<IIII', len(data), seq, 0, 0) + data, - self.checksum(data), - timeout=timeout) + for attempts_left in range(WRITE_BLOCK_ATTEMPTS - 1, -1, -1): + try: + self.check_command( + "Write encrypted to target Flash after seq %d" % seq, + self.ESP_FLASH_ENCRYPT_DATA, + struct.pack("<IIII", len(data), seq, 0, 0) + data, + self.checksum(data), + timeout=timeout, + ) + break + except FatalError: + if attempts_left: + self.trace( + "Encrypted block write failed, " + "retrying with {} attempts left".format(attempts_left) + ) + else: + raise """ Leave flash mode and run/reboot """ def flash_finish(self, reboot=False): @@ -798,12 +886,23 @@ class ESPLoader(object): return self.run_spiflash_command(SPIFLASH_RDID, b"", 24) def get_security_info(self): - # TODO: this only works on the ESP32S2 ROM code loader and needs to work in stub loader also res = self.check_command('get security info', self.ESP_GET_SECURITY_INFO, b'') - res = struct.unpack("<IBBBBBBBB", res) - flags, flash_crypt_cnt, key_purposes = res[0], res[1], res[2:] - # TODO: pack this as some kind of better data type - return (flags, flash_crypt_cnt, key_purposes) + esp32s2 = True if len(res) == 12 else False + res = struct.unpack("<IBBBBBBBB" if esp32s2 else "<IBBBBBBBBII", res) + return { + "flags": res[0], + "flash_crypt_cnt": res[1], + "key_purposes": res[2:9], + "chip_id": None if esp32s2 else res[9], + "api_version": None if esp32s2 else res[10], + } + + @esp32s3_or_newer_function_only + def get_chip_id(self): + res = self.check_command('get security info', self.ESP_GET_SECURITY_INFO, b'') + res = struct.unpack("<IBBBBBBBBI", res[:16]) # 4b flags, 1b flash_crypt_cnt, 7*1b key_purposes, 4b chip_id + chip_id = res[9] # 2/4 status bytes invariant + return chip_id @classmethod def parse_flash_size_arg(cls, arg): @@ -813,6 +912,14 @@ class ESPLoader(object): raise FatalError("Flash size '%s' is not supported by this chip type. Supported sizes: %s" % (arg, ", ".join(cls.FLASH_SIZES.keys()))) + @classmethod + def parse_flash_freq_arg(cls, arg): + try: + return cls.FLASH_FREQUENCY[arg] + except KeyError: + raise FatalError("Flash frequency '%s' is not supported by this chip type. Supported frequencies: %s" + % (arg, ", ".join(cls.FLASH_FREQUENCY.keys()))) + def run_stub(self, stub=None): if stub is None: stub = self.STUB_CODE @@ -860,7 +967,8 @@ class ESPLoader(object): timeout = timeout_per_mb(ERASE_REGION_TIMEOUT_PER_MB, write_size) # ROM performs the erase up front print("Compressed %d bytes to %d..." % (size, compsize)) params = struct.pack('<IIII', write_size, num_blocks, self.FLASH_WRITE_SIZE, offset) - if isinstance(self, (ESP32S2ROM, ESP32S3BETA2ROM, ESP32S3BETA3ROM, ESP32C3ROM, ESP32C6BETAROM)) and not self.IS_STUB: + if isinstance(self, (ESP32S2ROM, ESP32S3BETA2ROM, ESP32S3ROM, ESP32C3ROM, + ESP32C6BETAROM, ESP32H2BETA1ROM, ESP32C2ROM, ESP32H2BETA2ROM)) and not self.IS_STUB: params += struct.pack('<I', 0) # extra param is to enter encrypted flash mode via ROM (not supported currently) self.check_command("enter compressed flash mode", self.ESP_FLASH_DEFL_BEGIN, params, timeout=timeout) if size != 0 and not self.IS_STUB: @@ -868,11 +976,27 @@ class ESPLoader(object): print("Took %.2fs to erase flash block" % (time.time() - t)) return num_blocks - """ Write block to flash, send compressed """ @stub_and_esp32_function_only def flash_defl_block(self, data, seq, timeout=DEFAULT_TIMEOUT): - self.check_command("write compressed data to flash after seq %d" % seq, - self.ESP_FLASH_DEFL_DATA, struct.pack('<IIII', len(data), seq, 0, 0) + data, self.checksum(data), timeout=timeout) + """Write block to flash, send compressed, retry if fail""" + for attempts_left in range(WRITE_BLOCK_ATTEMPTS - 1, -1, -1): + try: + self.check_command( + "write compressed data to flash after seq %d" % seq, + self.ESP_FLASH_DEFL_DATA, + struct.pack("<IIII", len(data), seq, 0, 0) + data, + self.checksum(data), + timeout=timeout, + ) + break + except FatalError: + if attempts_left: + self.trace( + "Compressed block write failed, " + "retrying with {} attempts left".format(attempts_left) + ) + else: + raise """ Leave compressed flash mode and run/reboot """ @stub_and_esp32_function_only @@ -999,7 +1123,7 @@ class ESPLoader(object): self.check_command("set SPI params", ESP32ROM.ESP_SPI_SET_PARAMS, struct.pack('<IIIIII', fl_id, total_size, block_size, sector_size, page_size, status_mask)) - def run_spiflash_command(self, spiflash_command, data=b"", read_bits=0): + def run_spiflash_command(self, spiflash_command, data=b"", read_bits=0, addr=None, addr_len=0, dummy_len=0): """Run an arbitrary SPI flash command. This function uses the "USR_COMMAND" functionality in the ESP @@ -1013,20 +1137,23 @@ class ESPLoader(object): # SPI_USR register flags SPI_USR_COMMAND = (1 << 31) + SPI_USR_ADDR = (1 << 30) + SPI_USR_DUMMY = (1 << 29) SPI_USR_MISO = (1 << 28) SPI_USR_MOSI = (1 << 27) # SPI registers, base address differs ESP32* vs 8266 base = self.SPI_REG_BASE SPI_CMD_REG = base + 0x00 + SPI_ADDR_REG = base + 0x04 SPI_USR_REG = base + self.SPI_USR_OFFS SPI_USR1_REG = base + self.SPI_USR1_OFFS SPI_USR2_REG = base + self.SPI_USR2_OFFS SPI_W0_REG = base + self.SPI_W0_OFFS - # following two registers are ESP32 & 32S2/32C3 only + # following two registers are ESP32 and later chips only if self.SPI_MOSI_DLEN_OFFS is not None: - # ESP32/32S2/32C3 has a more sophisticated way to set up "user" commands + # ESP32 and later chips have a more sophisticated way to set up "user" commands def set_data_lengths(mosi_bits, miso_bits): SPI_MOSI_DLEN_REG = base + self.SPI_MOSI_DLEN_OFFS SPI_MISO_DLEN_REG = base + self.SPI_MISO_DLEN_OFFS @@ -1034,23 +1161,33 @@ class ESPLoader(object): self.write_reg(SPI_MOSI_DLEN_REG, mosi_bits - 1) if miso_bits > 0: self.write_reg(SPI_MISO_DLEN_REG, miso_bits - 1) + flags = 0 + if dummy_len > 0: + flags |= (dummy_len - 1) + if addr_len > 0: + flags |= (addr_len - 1) << SPI_USR_ADDR_LEN_SHIFT + if flags: + self.write_reg(SPI_USR1_REG, flags) else: - def set_data_lengths(mosi_bits, miso_bits): SPI_DATA_LEN_REG = SPI_USR1_REG SPI_MOSI_BITLEN_S = 17 SPI_MISO_BITLEN_S = 8 mosi_mask = 0 if (mosi_bits == 0) else (mosi_bits - 1) miso_mask = 0 if (miso_bits == 0) else (miso_bits - 1) - self.write_reg(SPI_DATA_LEN_REG, - (miso_mask << SPI_MISO_BITLEN_S) | ( - mosi_mask << SPI_MOSI_BITLEN_S)) + flags = (miso_mask << SPI_MISO_BITLEN_S) | (mosi_mask << SPI_MOSI_BITLEN_S) + if dummy_len > 0: + flags |= (dummy_len - 1) + if addr_len > 0: + flags |= (addr_len - 1) << SPI_USR_ADDR_LEN_SHIFT + self.write_reg(SPI_DATA_LEN_REG, flags) # SPI peripheral "command" bitmasks for SPI_CMD_REG SPI_CMD_USR = (1 << 18) # shift values SPI_USR2_COMMAND_LEN_SHIFT = 28 + SPI_USR_ADDR_LEN_SHIFT = 26 if read_bits > 32: raise FatalError("Reading more than 32 bits back from a SPI flash operation is unsupported") @@ -1065,10 +1202,16 @@ class ESPLoader(object): flags |= SPI_USR_MISO if data_bits > 0: flags |= SPI_USR_MOSI + if addr_len > 0: + flags |= SPI_USR_ADDR + if dummy_len > 0: + flags |= SPI_USR_DUMMY set_data_lengths(data_bits, read_bits) self.write_reg(SPI_USR_REG, flags) self.write_reg(SPI_USR2_REG, (7 << SPI_USR2_COMMAND_LEN_SHIFT) | spiflash_command) + if addr and addr_len > 0: + self.write_reg(SPI_ADDR_REG, addr) if data_bits == 0: self.write_reg(SPI_W0_REG, 0) # clear data register before we read it else: @@ -1093,6 +1236,10 @@ class ESPLoader(object): self.write_reg(SPI_USR2_REG, old_spi_usr2) return status + def read_spiflash_sfdp(self, addr, read_bits): + CMD_RDSFDP = 0x5A + return self.run_spiflash_command(CMD_RDSFDP, read_bits=read_bits, addr=addr, addr_len=24, dummy_len=8) + def read_status(self, num_bytes=2): """Read up to 24 bits (num_bytes) of SPI flash status register contents via RDSR, RDSR2, RDSR3 commands @@ -1191,6 +1338,17 @@ class ESPLoader(object): # in the stub loader self.command(self.ESP_RUN_USER_CODE, wait_response=False) + def check_chip_id(self): + try: + chip_id = self.get_chip_id() + if chip_id != self.IMAGE_CHIP_ID: + print("WARNING: Chip ID {} ({}) doesn't match expected Chip ID {}. esptool may not work correctly." + .format(chip_id, self.UNSUPPORTED_CHIPS.get(chip_id, 'Unknown'), self.IMAGE_CHIP_ID)) + # Try to flash anyways by disabling stub + self.stub_is_disabled = True + except NotImplementedInROMError: + pass + class ESP8266ROM(ESPLoader): """ Access class for ESP8266 ROM bootloader @@ -1229,6 +1387,13 @@ class ESP8266ROM(ESPLoader): '16MB': 0x90, } + FLASH_FREQUENCY = { + '80m': 0xf, + '40m': 0x0, + '26m': 0x1, + '20m': 0x2, + } + BOOTLOADER_FLASH_OFFSET = 0 MEMORY_MAP = [[0x3FF00000, 0x3FF00010, "DPORT"], @@ -1366,6 +1531,8 @@ class ESP32ROM(ESPLoader): IMAGE_CHIP_ID = 0 IS_STUB = False + FPGA_SLOW_BOOT = True + CHIP_DETECT_MAGIC_VALUE = [0x00f01d83] IROM_MAP_START = 0x400d0000 @@ -1389,6 +1556,9 @@ class ESP32ROM(ESPLoader): EFUSE_DIS_DOWNLOAD_MANUAL_ENCRYPT = (1 << 7) # EFUSE_RD_DISABLE_DL_ENCRYPT DR_REG_SYSCON_BASE = 0x3ff66000 + APB_CTL_DATE_ADDR = DR_REG_SYSCON_BASE + 0x7C + APB_CTL_DATE_V = 0x1 + APB_CTL_DATE_S = 31 SPI_W0_OFFS = 0x80 @@ -1401,7 +1571,17 @@ class ESP32ROM(ESPLoader): '2MB': 0x10, '4MB': 0x20, '8MB': 0x30, - '16MB': 0x40 + '16MB': 0x40, + '32MB': 0x50, + '64MB': 0x60, + '128MB': 0x70 + } + + FLASH_FREQUENCY = { + '80m': 0xf, + '40m': 0x0, + '26m': 0x1, + '20m': 0x2, } BOOTLOADER_FLASH_OFFSET = 0x1000 @@ -1485,28 +1665,37 @@ class ESP32ROM(ESPLoader): pkg_version += ((word3 >> 2) & 0x1) << 3 return pkg_version - def get_chip_revision(self): - word3 = self.read_efuse(3) - word5 = self.read_efuse(5) - apb_ctl_date = self.read_reg(self.DR_REG_SYSCON_BASE + 0x7C) + # Returns new version format based on major and minor versions + def get_chip_full_revision(self): + return self.get_major_chip_version() * 100 + self.get_minor_chip_version() - rev_bit0 = (word3 >> 15) & 0x1 - rev_bit1 = (word5 >> 20) & 0x1 - rev_bit2 = (apb_ctl_date >> 31) & 0x1 - if rev_bit0: - if rev_bit1: - if rev_bit2: - return 3 - else: - return 2 - else: - return 1 - return 0 + # Returns old version format (ECO number). Use the new format get_chip_full_revision(). + def get_chip_revision(self): + return self.get_major_chip_version() + + def get_minor_chip_version(self): + return (self.read_efuse(5) >> 24) & 0x3 + + def get_major_chip_version(self): + rev_bit0 = (self.read_efuse(3) >> 15) & 0x1 + rev_bit1 = (self.read_efuse(5) >> 20) & 0x1 + apb_ctl_date = self.read_reg(self.APB_CTL_DATE_ADDR) + rev_bit2 = (apb_ctl_date >> self.APB_CTL_DATE_S) & self.APB_CTL_DATE_V + combine_value = (rev_bit2 << 2) | (rev_bit1 << 1) | rev_bit0 + + revision = { + 0: 0, + 1: 1, + 3: 2, + 7: 3, + }.get(combine_value, 0) + return revision def get_chip_description(self): pkg_version = self.get_pkg_version() - chip_revision = self.get_chip_revision() - rev3 = (chip_revision == 3) + major_rev = self.get_major_chip_version() + minor_rev = self.get_minor_chip_version() + rev3 = major_rev == 3 single_core = self.read_efuse(3) & (1 << 0) # CHIP_VER DIS_APP_CPU chip_name = { @@ -1516,13 +1705,14 @@ class ESP32ROM(ESPLoader): 4: "ESP32-U4WDH", 5: "ESP32-PICO-V3" if rev3 else "ESP32-PICO-D4", 6: "ESP32-PICO-V3-02", + 7: "ESP32-D0WDR2-V3", }.get(pkg_version, "unknown ESP32") # ESP32-D0WD-V3, ESP32-D0WDQ6-V3 if chip_name.startswith("ESP32-D0WD") and rev3: chip_name += "-V3" - return "%s (revision %d)" % (chip_name, chip_revision) + return "%s (revision v%d.%d)" % (chip_name, major_rev, minor_rev) def get_chip_features(self): features = ["WiFi"] @@ -1638,6 +1828,8 @@ class ESP32S2ROM(ESP32ROM): CHIP_NAME = "ESP32-S2" IMAGE_CHIP_ID = 2 + FPGA_SLOW_BOOT = False + IROM_MAP_START = 0x40080000 IROM_MAP_END = 0x40b80000 DROM_MAP_START = 0x3F000000 @@ -1662,6 +1854,8 @@ class ESP32S2ROM(ESP32ROM): # todo: use espefuse APIs to get this info EFUSE_BASE = 0x3f41A000 EFUSE_RD_REG_BASE = EFUSE_BASE + 0x030 # BLOCK0 read base address + EFUSE_BLOCK1_ADDR = EFUSE_BASE + 0x044 + EFUSE_BLOCK2_ADDR = EFUSE_BASE + 0x05C EFUSE_PURPOSE_KEY0_REG = EFUSE_BASE + 0x34 EFUSE_PURPOSE_KEY0_SHIFT = 24 @@ -1706,21 +1900,50 @@ class ESP32S2ROM(ESP32ROM): [0x40080000, 0x40800000, "IROM"], [0x50000000, 0x50002000, "RTC_DATA"]] + # Returns old version format (ECO number). Use the new format get_chip_full_revision(). + def get_chip_revision(self): + return self.get_major_chip_version() + def get_pkg_version(self): + num_word = 4 + return (self.read_reg(self.EFUSE_BLOCK1_ADDR + (4 * num_word)) >> 0) & 0x0F + + def get_minor_chip_version(self): + hi_num_word = 3 + hi = (self.read_reg(self.EFUSE_BLOCK1_ADDR + (4 * hi_num_word)) >> 20) & 0x01 + low_num_word = 4 + low = (self.read_reg(self.EFUSE_BLOCK1_ADDR + (4 * low_num_word)) >> 4) & 0x07 + return (hi << 3) + low + + def get_major_chip_version(self): num_word = 3 - block1_addr = self.EFUSE_BASE + 0x044 - word3 = self.read_reg(block1_addr + (4 * num_word)) - pkg_version = (word3 >> 21) & 0x0F - return pkg_version + return (self.read_reg(self.EFUSE_BLOCK1_ADDR + (4 * num_word)) >> 18) & 0x03 + + def get_flash_version(self): + num_word = 3 + return (self.read_reg(self.EFUSE_BLOCK1_ADDR + (4 * num_word)) >> 21) & 0x0F + + def get_psram_version(self): + num_word = 3 + return (self.read_reg(self.EFUSE_BLOCK1_ADDR + (4 * num_word)) >> 28) & 0x0F + + def get_block2_version(self): + # BLK_VERSION_MINOR + num_word = 4 + return (self.read_reg(self.EFUSE_BLOCK2_ADDR + (4 * num_word)) >> 4) & 0x07 def get_chip_description(self): chip_name = { 0: "ESP32-S2", - 1: "ESP32-S2FH16", - 2: "ESP32-S2FH32", - }.get(self.get_pkg_version(), "unknown ESP32-S2") + 1: "ESP32-S2FH2", + 2: "ESP32-S2FH4", + 102: "ESP32-S2FNR2", + 100: "ESP32-S2R2", + }.get(self.get_flash_version() + self.get_psram_version() * 100, "unknown ESP32-S2") - return "%s" % (chip_name) + major_rev = self.get_major_chip_version() + minor_rev = self.get_minor_chip_version() + return "%s (revision v%d.%d)" % (chip_name, major_rev, minor_rev) def get_chip_features(self): features = ["WiFi"] @@ -1728,22 +1951,27 @@ class ESP32S2ROM(ESP32ROM): if self.secure_download_mode: features += ["Secure Download Mode Enabled"] - pkg_version = self.get_pkg_version() + flash_version = { + 0: "No Embedded Flash", + 1: "Embedded Flash 2MB", + 2: "Embedded Flash 4MB", + }.get(self.get_flash_version(), "Unknown Embedded Flash") + features += [flash_version] - if pkg_version in [1, 2]: - if pkg_version == 1: - features += ["Embedded 2MB Flash"] - elif pkg_version == 2: - features += ["Embedded 4MB Flash"] - features += ["105C temp rating"] + psram_version = { + 0: "No Embedded PSRAM", + 1: "Embedded PSRAM 2MB", + 2: "Embedded PSRAM 4MB", + }.get(self.get_psram_version(), "Unknown Embedded PSRAM") + features += [psram_version] - num_word = 4 - block2_addr = self.EFUSE_BASE + 0x05C - word4 = self.read_reg(block2_addr + (4 * num_word)) - block2_version = (word4 >> 4) & 0x07 + block2_version = { + 0: "No calibration in BLK2 of efuse", + 1: "ADC and temperature sensor calibration in BLK2 of efuse V1", + 2: "ADC and temperature sensor calibration in BLK2 of efuse V2", + }.get(self.get_block2_version(), "Unknown Calibration in BLK2") + features += [block2_version] - if block2_version == 1: - features += ["ADC and temperature sensor calibration in BLK2 of efuse"] return features def get_crystal_freq(self): @@ -1810,16 +2038,17 @@ class ESP32S2ROM(ESP32ROM): strap_reg = self.read_reg(self.GPIO_STRAP_REG) force_dl_reg = self.read_reg(self.RTC_CNTL_OPTION1_REG) if strap_reg & self.GPIO_STRAP_SPI_BOOT_MASK == 0 and force_dl_reg & self.RTC_CNTL_FORCE_DOWNLOAD_BOOT_MASK == 0: - print("ERROR: {} chip was placed into download mode using GPIO0.\n" + print("WARNING: {} chip was placed into download mode using GPIO0.\n" "esptool.py can not exit the download mode over USB. " "To run the app, reset the chip manually.\n" - "To suppress this error, set --after option to 'no_reset'.".format(self.get_chip_description())) + "To suppress this note, set --after option to 'no_reset'.".format(self.get_chip_description())) raise SystemExit(1) def hard_reset(self): if self.uses_usb(): self._check_if_can_reset() + print('Hard resetting via RTS pin...') self._setRTS(True) # EN->LOW if self.uses_usb(): # Give the chip some time to come out of reset, to be able to handle further DTR/RTS transitions @@ -1827,12 +2056,21 @@ class ESP32S2ROM(ESP32ROM): self._setRTS(False) time.sleep(0.2) else: + time.sleep(0.1) self._setRTS(False) class ESP32S3ROM(ESP32ROM): CHIP_NAME = "ESP32-S3" + IMAGE_CHIP_ID = 9 + + CHIP_DETECT_MAGIC_VALUE = [0x9] + + BOOTLOADER_FLASH_OFFSET = 0x0 + + FPGA_SLOW_BOOT = False + IROM_MAP_START = 0x42000000 IROM_MAP_END = 0x44000000 DROM_MAP_START = 0x3c000000 @@ -1851,9 +2089,10 @@ class ESP32S3ROM(ESP32ROM): FLASH_ENCRYPTED_WRITE_ALIGN = 16 # todo: use espefuse APIs to get this info - EFUSE_BASE = 0x6001A000 # BLOCK0 read base address + EFUSE_BASE = 0x60007000 # BLOCK0 read base address MAC_EFUSE_REG = EFUSE_BASE + 0x044 - + EFUSE_BLOCK1_ADDR = EFUSE_BASE + 0x44 + EFUSE_BLOCK2_ADDR = EFUSE_BASE + 0x5C EFUSE_RD_REG_BASE = EFUSE_BASE + 0x030 # BLOCK0 read base address EFUSE_PURPOSE_KEY0_REG = EFUSE_BASE + 0x34 @@ -1876,9 +2115,17 @@ class ESP32S3ROM(ESP32ROM): PURPOSE_VAL_XTS_AES256_KEY_2 = 3 PURPOSE_VAL_XTS_AES128_KEY = 4 - UART_CLKDIV_REG = 0x60000014 + UARTDEV_BUF_NO = 0x3fcef14c # Variable in ROM .bss which indicates the port in use + UARTDEV_BUF_NO_USB = 3 # Value of the above variable indicating that USB is in use + + USB_RAM_BLOCK = 0x800 # Max block size USB CDC is used GPIO_STRAP_REG = 0x60004038 + GPIO_STRAP_SPI_BOOT_MASK = 0x8 # Not download mode + RTC_CNTL_OPTION1_REG = 0x6000812C + RTC_CNTL_FORCE_DOWNLOAD_BOOT_MASK = 0x1 # Is download mode forced over USB? + + UART_CLKDIV_REG = 0x60000014 MEMORY_MAP = [[0x00000000, 0x00010000, "PADDING"], [0x3C000000, 0x3D000000, "DROM"], @@ -1893,8 +2140,57 @@ class ESP32S3ROM(ESP32ROM): [0x42000000, 0x42800000, "IROM"], [0x50000000, 0x50002000, "RTC_DATA"]] + # Returns old version format (ECO number). Use the new format get_chip_full_revision(). + def get_chip_revision(self): + return self.get_minor_chip_version() + + def get_pkg_version(self): + num_word = 3 + return (self.read_reg(self.EFUSE_BLOCK1_ADDR + (4 * num_word)) >> 21) & 0x07 + + def is_eco0(self, minor_raw): + # Workaround: The major version field was allocated to other purposes + # when block version is v1.1. + # Luckily only chip v0.0 have this kind of block version and efuse usage. + return ( + (minor_raw & 0x7) == 0 and self.get_blk_version_major() == 1 and self.get_blk_version_minor() == 1 + ) + + def get_minor_chip_version(self): + minor_raw = self.get_raw_minor_chip_version() + if self.is_eco0(minor_raw): + return 0 + return minor_raw + + def get_raw_minor_chip_version(self): + hi_num_word = 5 + hi = (self.read_reg(self.EFUSE_BLOCK1_ADDR + (4 * hi_num_word)) >> 23) & 0x01 + low_num_word = 3 + low = (self.read_reg(self.EFUSE_BLOCK1_ADDR + (4 * low_num_word)) >> 18) & 0x07 + return (hi << 3) + low + + def get_blk_version_major(self): + num_word = 4 + return (self.read_reg(self.EFUSE_BLOCK2_ADDR + (4 * num_word)) >> 0) & 0x03 + + def get_blk_version_minor(self): + num_word = 3 + return (self.read_reg(self.EFUSE_BLOCK1_ADDR + (4 * num_word)) >> 24) & 0x07 + + def get_major_chip_version(self): + minor_raw = self.get_raw_minor_chip_version() + if self.is_eco0(minor_raw): + return 0 + return self.get_raw_major_chip_version() + + def get_raw_major_chip_version(self): + num_word = 5 + return (self.read_reg(self.EFUSE_BLOCK1_ADDR + (4 * num_word)) >> 24) & 0x03 + def get_chip_description(self): - return "ESP32-S3" + major_rev = self.get_major_chip_version() + minor_rev = self.get_minor_chip_version() + return "%s (revision v%d.%d)" % (self.CHIP_NAME, major_rev, minor_rev) def get_chip_features(self): return ["WiFi", "BLE"] @@ -1940,6 +2236,50 @@ class ESP32S3ROM(ESP32ROM): except TypeError: # Python 3, bitstring elements are already bytes return tuple(bitstring) + def uses_usb(self, _cache=[]): + if self.secure_download_mode: + return False # can't detect native USB in secure download mode + if not _cache: + buf_no = self.read_reg(self.UARTDEV_BUF_NO) & 0xff + _cache.append(buf_no == self.UARTDEV_BUF_NO_USB) + return _cache[0] + + def _post_connect(self): + if self.uses_usb(): + self.ESP_RAM_BLOCK = self.USB_RAM_BLOCK + + def _check_if_can_reset(self): + """ + Check the strapping register to see if we can reset out of download mode. + """ + if os.getenv("ESPTOOL_TESTING") is not None: + print("ESPTOOL_TESTING is set, ignoring strapping mode check") + # Esptool tests over USB CDC run with GPIO0 strapped low, don't complain in this case. + return + strap_reg = self.read_reg(self.GPIO_STRAP_REG) + force_dl_reg = self.read_reg(self.RTC_CNTL_OPTION1_REG) + if strap_reg & self.GPIO_STRAP_SPI_BOOT_MASK == 0 and force_dl_reg & self.RTC_CNTL_FORCE_DOWNLOAD_BOOT_MASK == 0: + print("WARNING: {} chip was placed into download mode using GPIO0.\n" + "esptool.py can not exit the download mode over USB. " + "To run the app, reset the chip manually.\n" + "To suppress this note, set --after option to 'no_reset'.".format(self.get_chip_description())) + raise SystemExit(1) + + def hard_reset(self): + if self.uses_usb(): + self._check_if_can_reset() + + print('Hard resetting via RTS pin...') + self._setRTS(True) # EN->LOW + if self.uses_usb(): + # Give the chip some time to come out of reset, to be able to handle further DTR/RTS transitions + time.sleep(0.2) + self._setRTS(False) + time.sleep(0.2) + else: + time.sleep(0.1) + self._setRTS(False) + class ESP32S3BETA2ROM(ESP32S3ROM): CHIP_NAME = "ESP32-S3(beta2)" @@ -1947,24 +2287,20 @@ class ESP32S3BETA2ROM(ESP32S3ROM): CHIP_DETECT_MAGIC_VALUE = [0xeb004136] - def get_chip_description(self): - return "ESP32-S3(beta2)" - - -class ESP32S3BETA3ROM(ESP32S3ROM): - CHIP_NAME = "ESP32-S3(beta3)" - IMAGE_CHIP_ID = 6 - - CHIP_DETECT_MAGIC_VALUE = [0x9] + EFUSE_BASE = 0x6001A000 # BLOCK0 read base address def get_chip_description(self): - return "ESP32-S3(beta3)" + major_rev = self.get_major_chip_version() + minor_rev = self.get_minor_chip_version() + return "%s (revision v%d.%d)" % (self.CHIP_NAME, major_rev, minor_rev) class ESP32C3ROM(ESP32ROM): CHIP_NAME = "ESP32-C3" IMAGE_CHIP_ID = 5 + FPGA_SLOW_BOOT = False + IROM_MAP_START = 0x42000000 IROM_MAP_END = 0x42800000 DROM_MAP_START = 0x3c000000 @@ -1986,6 +2322,7 @@ class ESP32C3ROM(ESP32ROM): UART_DATE_REG_ADDR = 0x60000000 + 0x7c EFUSE_BASE = 0x60008800 + EFUSE_BLOCK1_ADDR = EFUSE_BASE + 0x044 MAC_EFUSE_REG = EFUSE_BASE + 0x044 EFUSE_RD_REG_BASE = EFUSE_BASE + 0x030 # BLOCK0 read base address @@ -2024,27 +2361,32 @@ class ESP32C3ROM(ESP32ROM): [0x50000000, 0x50002000, "RTC_DRAM"], [0x600FE000, 0x60100000, "MEM_INTERNAL2"]] + # Returns old version format (ECO number). Use the new format get_chip_full_revision(). + def get_chip_revision(self): + return self.get_minor_chip_version() + def get_pkg_version(self): num_word = 3 - block1_addr = self.EFUSE_BASE + 0x044 - word3 = self.read_reg(block1_addr + (4 * num_word)) - pkg_version = (word3 >> 21) & 0x0F - return pkg_version + return (self.read_reg(self.EFUSE_BLOCK1_ADDR + (4 * num_word)) >> 21) & 0x07 - def get_chip_revision(self): - # reads WAFER_VERSION field from EFUSE_RD_MAC_SPI_SYS_3_REG - block1_addr = self.EFUSE_BASE + 0x044 - num_word = 3 - pos = 18 - return (self.read_reg(block1_addr + (4 * num_word)) & (0x7 << pos)) >> pos + def get_minor_chip_version(self): + hi_num_word = 5 + hi = (self.read_reg(self.EFUSE_BLOCK1_ADDR + (4 * hi_num_word)) >> 23) & 0x01 + low_num_word = 3 + low = (self.read_reg(self.EFUSE_BLOCK1_ADDR + (4 * low_num_word)) >> 18) & 0x07 + return (hi << 3) + low + + def get_major_chip_version(self): + num_word = 5 + return (self.read_reg(self.EFUSE_BLOCK1_ADDR + (4 * num_word)) >> 24) & 0x03 def get_chip_description(self): chip_name = { 0: "ESP32-C3", }.get(self.get_pkg_version(), "unknown ESP32-C3") - chip_revision = self.get_chip_revision() - - return "%s (revision %d)" % (chip_name, chip_revision) + major_rev = self.get_major_chip_version() + minor_rev = self.get_minor_chip_version() + return "%s (revision v%d.%d)" % (chip_name, major_rev, minor_rev) def get_chip_features(self): return ["Wi-Fi"] @@ -2087,21 +2429,226 @@ class ESP32C3ROM(ESP32ROM): return any(p == self.PURPOSE_VAL_XTS_AES128_KEY for p in purposes) +class ESP32H2BETA1ROM(ESP32ROM): + CHIP_NAME = "ESP32-H2(beta1)" + IMAGE_CHIP_ID = 10 + + IROM_MAP_START = 0x42000000 + IROM_MAP_END = 0x42800000 + DROM_MAP_START = 0x3c000000 + DROM_MAP_END = 0x3c800000 + + SPI_REG_BASE = 0x60002000 + SPI_USR_OFFS = 0x18 + SPI_USR1_OFFS = 0x1C + SPI_USR2_OFFS = 0x20 + SPI_MOSI_DLEN_OFFS = 0x24 + SPI_MISO_DLEN_OFFS = 0x28 + SPI_W0_OFFS = 0x58 + + BOOTLOADER_FLASH_OFFSET = 0x0 + + CHIP_DETECT_MAGIC_VALUE = [0xca26cc22] + + UART_DATE_REG_ADDR = 0x60000000 + 0x7c + + EFUSE_BASE = 0x6001A000 + EFUSE_BLOCK1_ADDR = EFUSE_BASE + 0x044 + MAC_EFUSE_REG = EFUSE_BASE + 0x044 + + EFUSE_RD_REG_BASE = EFUSE_BASE + 0x030 # BLOCK0 read base address + + EFUSE_PURPOSE_KEY0_REG = EFUSE_BASE + 0x34 + EFUSE_PURPOSE_KEY0_SHIFT = 24 + EFUSE_PURPOSE_KEY1_REG = EFUSE_BASE + 0x34 + EFUSE_PURPOSE_KEY1_SHIFT = 28 + EFUSE_PURPOSE_KEY2_REG = EFUSE_BASE + 0x38 + EFUSE_PURPOSE_KEY2_SHIFT = 0 + EFUSE_PURPOSE_KEY3_REG = EFUSE_BASE + 0x38 + EFUSE_PURPOSE_KEY3_SHIFT = 4 + EFUSE_PURPOSE_KEY4_REG = EFUSE_BASE + 0x38 + EFUSE_PURPOSE_KEY4_SHIFT = 8 + EFUSE_PURPOSE_KEY5_REG = EFUSE_BASE + 0x38 + EFUSE_PURPOSE_KEY5_SHIFT = 12 + + EFUSE_DIS_DOWNLOAD_MANUAL_ENCRYPT_REG = EFUSE_RD_REG_BASE + EFUSE_DIS_DOWNLOAD_MANUAL_ENCRYPT = 1 << 20 + + PURPOSE_VAL_XTS_AES128_KEY = 4 + + GPIO_STRAP_REG = 0x3f404038 + + FLASH_ENCRYPTED_WRITE_ALIGN = 16 + + MEMORY_MAP = [] + + FLASH_FREQUENCY = { + '48m': 0xf, + '24m': 0x0, + '16m': 0x1, + '12m': 0x2, + } + + # Returns old version format (ECO number). Use the new format get_chip_full_revision(). + def get_chip_revision(self): + return 0 + + def get_pkg_version(self): + num_word = 4 + return (self.read_reg(self.EFUSE_BLOCK1_ADDR + (4 * num_word)) >> 0) & 0x07 + + def get_minor_chip_version(self): + num_word = 3 + return (self.read_reg(self.EFUSE_BLOCK1_ADDR + (4 * num_word)) >> 18) & 0x07 + + def get_major_chip_version(self): + num_word = 3 + return (self.read_reg(self.EFUSE_BLOCK1_ADDR + (4 * num_word)) >> 21) & 0x03 + + def get_chip_description(self): + chip_name = { + 0: "ESP32-H2", + }.get(self.get_pkg_version(), "unknown ESP32-H2") + major_rev = self.get_major_chip_version() + minor_rev = self.get_minor_chip_version() + return "%s (revision v%d.%d)" % (chip_name, major_rev, minor_rev) + + def get_chip_features(self): + return ["BLE/802.15.4"] + + def get_crystal_freq(self): + return 32 + + def override_vddsdio(self, new_voltage): + raise NotImplementedInROMError("VDD_SDIO overrides are not supported for ESP32-H2") + + def read_mac(self): + mac0 = self.read_reg(self.MAC_EFUSE_REG) + mac1 = self.read_reg(self.MAC_EFUSE_REG + 4) # only bottom 16 bits are MAC + bitstring = struct.pack(">II", mac1, mac0)[2:] + try: + return tuple(ord(b) for b in bitstring) + except TypeError: # Python 3, bitstring elements are already bytes + return tuple(bitstring) + + def get_flash_crypt_config(self): + return None # doesn't exist on ESP32-H2 + + def get_key_block_purpose(self, key_block): + if key_block < 0 or key_block > 5: + raise FatalError("Valid key block numbers must be in range 0-5") + + reg, shift = [(self.EFUSE_PURPOSE_KEY0_REG, self.EFUSE_PURPOSE_KEY0_SHIFT), + (self.EFUSE_PURPOSE_KEY1_REG, self.EFUSE_PURPOSE_KEY1_SHIFT), + (self.EFUSE_PURPOSE_KEY2_REG, self.EFUSE_PURPOSE_KEY2_SHIFT), + (self.EFUSE_PURPOSE_KEY3_REG, self.EFUSE_PURPOSE_KEY3_SHIFT), + (self.EFUSE_PURPOSE_KEY4_REG, self.EFUSE_PURPOSE_KEY4_SHIFT), + (self.EFUSE_PURPOSE_KEY5_REG, self.EFUSE_PURPOSE_KEY5_SHIFT)][key_block] + return (self.read_reg(reg) >> shift) & 0xF + + def is_flash_encryption_key_valid(self): + # Need to see an AES-128 key + purposes = [self.get_key_block_purpose(b) for b in range(6)] + + return any(p == self.PURPOSE_VAL_XTS_AES128_KEY for p in purposes) + + +class ESP32H2BETA2ROM(ESP32H2BETA1ROM): + CHIP_NAME = "ESP32-H2(beta2)" + IMAGE_CHIP_ID = 14 + + def get_chip_description(self): + major_rev = self.get_major_chip_version() + minor_rev = self.get_minor_chip_version() + return "%s (revision v%d.%d)" % (self.CHIP_NAME, major_rev, minor_rev) + + +class ESP32C2ROM(ESP32C3ROM): + CHIP_NAME = "ESP32-C2" + IMAGE_CHIP_ID = 12 + + IROM_MAP_START = 0x42000000 + IROM_MAP_END = 0x42400000 + DROM_MAP_START = 0x3c000000 + DROM_MAP_END = 0x3c400000 + + # Magic value for ESP32C2 ECO0 and ECO1 respectively + CHIP_DETECT_MAGIC_VALUE = [0x6F51306F, 0x7c41a06f] + + EFUSE_BASE = 0x60008800 + EFUSE_BLOCK2_ADDR = EFUSE_BASE + 0x040 + MAC_EFUSE_REG = EFUSE_BASE + 0x040 + + FLASH_FREQUENCY = { + '60m': 0xf, + '30m': 0x0, + '20m': 0x1, + '15m': 0x2, + } + + # Returns old version format (ECO number). Use the new format get_chip_full_revision(). + def get_chip_revision(self): + return self.get_major_chip_version() + + def get_pkg_version(self): + num_word = 1 + return (self.read_reg(self.EFUSE_BLOCK2_ADDR + (4 * num_word)) >> 22) & 0x07 + + def get_chip_description(self): + chip_name = { + 0: "ESP32-C2", + 1: "ESP32-C2", + }.get(self.get_pkg_version(), "unknown ESP32-C2") + major_rev = self.get_major_chip_version() + minor_rev = self.get_minor_chip_version() + return "%s (revision v%d.%d)" % (chip_name, major_rev, minor_rev) + + def get_minor_chip_version(self): + num_word = 1 + return (self.read_reg(self.EFUSE_BLOCK2_ADDR + (4 * num_word)) >> 16) & 0xF + + def get_major_chip_version(self): + num_word = 1 + return (self.read_reg(self.EFUSE_BLOCK2_ADDR + (4 * num_word)) >> 20) & 0x3 + + def _post_connect(self): + # ESP32C2 ECO0 is no longer supported by the flasher stub + if self.get_chip_revision() == 0: + self.stub_is_disabled = True + self.IS_STUB = False + + class ESP32C6BETAROM(ESP32C3ROM): - CHIP_NAME = "ESP32-C6 BETA" + CHIP_NAME = "ESP32-C6(beta)" IMAGE_CHIP_ID = 7 CHIP_DETECT_MAGIC_VALUE = [0x0da1806f] UART_DATE_REG_ADDR = 0x00000500 + # Returns old version format (ECO number). Use the new format get_chip_full_revision(). + def get_chip_revision(self): + return 0 + + def get_pkg_version(self): + num_word = 3 + return (self.read_reg(self.EFUSE_BLOCK1_ADDR + (4 * num_word)) >> 29) & 0x07 + + def get_minor_chip_version(self): + num_word = 3 + return (self.read_reg(self.EFUSE_BLOCK1_ADDR + (4 * num_word)) >> 18) & 0x0F + + def get_major_chip_version(self): + num_word = 3 + return (self.read_reg(self.EFUSE_BLOCK1_ADDR + (4 * num_word)) >> 22) & 0x03 + def get_chip_description(self): chip_name = { 0: "ESP32-C6", }.get(self.get_pkg_version(), "unknown ESP32-C6") - chip_revision = self.get_chip_revision() - - return "%s (revision %d)" % (chip_name, chip_revision) + major_rev = self.get_major_chip_version() + minor_rev = self.get_minor_chip_version() + return "%s (revision v%d.%d)" % (chip_name, major_rev, minor_rev) class ESP32StubLoader(ESP32ROM): @@ -2165,7 +2712,7 @@ class ESP32S3BETA2StubLoader(ESP32S3BETA2ROM): ESP32S3BETA2ROM.STUB_CLASS = ESP32S3BETA2StubLoader -class ESP32S3BETA3StubLoader(ESP32S3BETA3ROM): +class ESP32S3StubLoader(ESP32S3ROM): """ Access class for ESP32S3 stub loader, runs on top of ROM. (Basically the same as ESP32StubLoader, but different base class. @@ -2181,8 +2728,12 @@ class ESP32S3BETA3StubLoader(ESP32S3BETA3ROM): self._trace_enabled = rom_loader._trace_enabled self.flush_input() # resets _slip_reader + if rom_loader.uses_usb(): + self.ESP_RAM_BLOCK = self.USB_RAM_BLOCK + self.FLASH_WRITE_SIZE = self.USB_RAM_BLOCK -ESP32S3BETA3ROM.STUB_CLASS = ESP32S3BETA3StubLoader + +ESP32S3ROM.STUB_CLASS = ESP32S3StubLoader class ESP32C3StubLoader(ESP32C3ROM): @@ -2205,6 +2756,66 @@ class ESP32C3StubLoader(ESP32C3ROM): ESP32C3ROM.STUB_CLASS = ESP32C3StubLoader +class ESP32H2BETA1StubLoader(ESP32H2BETA1ROM): + """ Access class for ESP32H2BETA1 stub loader, runs on top of ROM. + + (Basically the same as ESP32StubLoader, but different base class. + Can possibly be made into a mixin.) + """ + FLASH_WRITE_SIZE = 0x4000 # matches MAX_WRITE_BLOCK in stub_loader.c + STATUS_BYTES_LENGTH = 2 # same as ESP8266, different to ESP32 ROM + IS_STUB = True + + def __init__(self, rom_loader): + self.secure_download_mode = rom_loader.secure_download_mode + self._port = rom_loader._port + self._trace_enabled = rom_loader._trace_enabled + self.flush_input() # resets _slip_reader + + +ESP32H2BETA1ROM.STUB_CLASS = ESP32H2BETA1StubLoader + + +class ESP32H2BETA2StubLoader(ESP32H2BETA2ROM): + """ Access class for ESP32H2BETA2 stub loader, runs on top of ROM. + + (Basically the same as ESP32StubLoader, but different base class. + Can possibly be made into a mixin.) + """ + FLASH_WRITE_SIZE = 0x4000 # matches MAX_WRITE_BLOCK in stub_loader.c + STATUS_BYTES_LENGTH = 2 # same as ESP8266, different to ESP32 ROM + IS_STUB = True + + def __init__(self, rom_loader): + self.secure_download_mode = rom_loader.secure_download_mode + self._port = rom_loader._port + self._trace_enabled = rom_loader._trace_enabled + self.flush_input() # resets _slip_reader + + +ESP32H2BETA2ROM.STUB_CLASS = ESP32H2BETA2StubLoader + + +class ESP32C2StubLoader(ESP32C2ROM): + """ Access class for ESP32C2 stub loader, runs on top of ROM. + + (Basically the same as ESP32StubLoader, but different base class. + Can possibly be made into a mixin.) + """ + FLASH_WRITE_SIZE = 0x4000 # matches MAX_WRITE_BLOCK in stub_loader.c + STATUS_BYTES_LENGTH = 2 # same as ESP8266, different to ESP32 ROM + IS_STUB = True + + def __init__(self, rom_loader): + self.secure_download_mode = rom_loader.secure_download_mode + self._port = rom_loader._port + self._trace_enabled = rom_loader._trace_enabled + self.flush_input() # resets _slip_reader + + +ESP32C2ROM.STUB_CLASS = ESP32C2StubLoader + + class ESPBOOTLOADER(object): """ These are constants related to software ESP8266 bootloader, working with 'v2' image files """ @@ -2223,7 +2834,7 @@ def LoadFirmwareImage(chip, filename): Returns a BaseFirmwareImage subclass, either ESP8266ROMFirmwareImage (v1) or ESP8266V2FirmwareImage (v2). """ - chip = chip.lower().replace("-", "") + chip = re.sub(r"[-()]", "", chip.lower()) with open(filename, 'rb') as f: if chip == 'esp32': return ESP32FirmwareImage(f) @@ -2231,12 +2842,18 @@ def LoadFirmwareImage(chip, filename): return ESP32S2FirmwareImage(f) elif chip == "esp32s3beta2": return ESP32S3BETA2FirmwareImage(f) - elif chip == "esp32s3beta3": - return ESP32S3BETA3FirmwareImage(f) + elif chip == "esp32s3": + return ESP32S3FirmwareImage(f) elif chip == 'esp32c3': return ESP32C3FirmwareImage(f) elif chip == 'esp32c6beta': return ESP32C6BETAFirmwareImage(f) + elif chip == 'esp32h2beta1': + return ESP32H2BETA1FirmwareImage(f) + elif chip == 'esp32h2beta2': + return ESP32H2BETA2FirmwareImage(f) + elif chip == 'esp32c2': + return ESP32C2FirmwareImage(f) else: # Otherwise, ESP8266 so look at magic to determine the image type magic = ord(f.read(1)) f.seek(0) @@ -2314,6 +2931,7 @@ class BaseFirmwareImage(object): self.entrypoint = 0 self.elf_sha256 = None self.elf_sha256_offset = 0 + self.pad_to_size = 0 def load_common_header(self, load_file, expected_magic): (magic, segments, self.flash_mode, self.flash_size_freq, self.entrypoint) = struct.unpack('<BBBBI', load_file.read(8)) @@ -2361,7 +2979,7 @@ class BaseFirmwareImage(object): if segment_data[patch_offset:patch_offset + self.SHA256_DIGEST_LEN] != b'\x00' * self.SHA256_DIGEST_LEN: raise FatalError('Contents of segment at SHA256 digest offset 0x%x are not all zero. Refusing to overwrite.' % self.elf_sha256_offset) - assert(len(self.elf_sha256) == self.SHA256_DIGEST_LEN) + assert len(self.elf_sha256) == self.SHA256_DIGEST_LEN segment_data = segment_data[0:patch_offset] + self.elf_sha256 + \ segment_data[patch_offset + self.SHA256_DIGEST_LEN:] return segment_data @@ -2453,6 +3071,10 @@ class BaseFirmwareImage(object): # script will have produced any adjacent sections in linear order in the ELF, anyhow. self.segments = segments + def set_mmu_page_size(self, size): + """ If supported, this should be overridden by the chip-specific class. Gets called in elf2image. """ + print('WARNING: Changing MMU page size is not supported on {}! Defaulting to 64KB.'.format(self.ROM_LOADER.CHIP_NAME)) + class ESP8266ROMFirmwareImage(BaseFirmwareImage): """ 'Version 1' firmware image, segments loaded directly by the ROM bootloader. """ @@ -2586,11 +3208,6 @@ class ESP8266V2FirmwareImage(BaseFirmwareImage): f.write(struct.pack(b'<I', crc)) -# Backwards compatibility for previous API, remove in esptool.py V3 -ESPFirmwareImage = ESP8266ROMFirmwareImage -OTAFirmwareImage = ESP8266V2FirmwareImage - - def esp8266_crc32(data): """ CRC32 algorithm used by 8266 SDK bootloader (and gen_appbin.py). @@ -2617,7 +3234,7 @@ class ESP32FirmwareImage(BaseFirmwareImage): # to be set to this value so ROM bootloader will skip it. WP_PIN_DISABLED = 0xEE - EXTENDED_HEADER_STRUCT_FMT = "<BBBBHB" + ("B" * 8) + "B" + EXTENDED_HEADER_STRUCT_FMT = "<BBBBHBHH" + ("B" * 4) + "B" IROM_ALIGN = 65536 @@ -2636,6 +3253,8 @@ class ESP32FirmwareImage(BaseFirmwareImage): self.hd_drv = 0 self.wp_drv = 0 self.min_rev = 0 + self.min_rev_full = 0 + self.max_rev_full = 0 self.append_digest = True @@ -2782,6 +3401,12 @@ class ESP32FirmwareImage(BaseFirmwareImage): digest.update(f.read(image_length)) f.write(digest.digest()) + if self.pad_to_size: + image_length = f.tell() + if image_length % self.pad_to_size != 0: + pad_by = self.pad_to_size - (image_length % self.pad_to_size) + f.write(b"\xff" * pad_by) + with open(filename, 'wb') as real_file: real_file.write(f.getvalue()) @@ -2813,8 +3438,12 @@ class ESP32FirmwareImage(BaseFirmwareImage): print(("Unexpected chip id in image. Expected %d but value was %d. " "Is this image for a different chip model?") % (self.ROM_LOADER.IMAGE_CHIP_ID, chip_id)) + self.min_rev = fields[5] + self.min_rev_full = fields[6] + self.max_rev_full = fields[7] + # reserved fields in the middle should all be zero - if any(f for f in fields[6:-1] if f != 0): + if any(f for f in fields[8:-1] if f != 0): print("Warning: some reserved header fields have non-zero values. This image may be from a newer esptool.py?") append_digest = fields[-1] # last byte is append_digest @@ -2834,14 +3463,110 @@ class ESP32FirmwareImage(BaseFirmwareImage): join_byte(self.d_drv, self.cs_drv), join_byte(self.hd_drv, self.wp_drv), self.ROM_LOADER.IMAGE_CHIP_ID, - self.min_rev] - fields += [0] * 8 # padding + self.min_rev, + self.min_rev_full, + self.max_rev_full] + fields += [0] * 4 # padding fields += [append_digest] packed = struct.pack(self.EXTENDED_HEADER_STRUCT_FMT, *fields) save_file.write(packed) +class ESP8266V3FirmwareImage(ESP32FirmwareImage): + """ ESP8266 V3 firmware image is very similar to ESP32 image + """ + + EXTENDED_HEADER_STRUCT_FMT = "B" * 16 + + def is_flash_addr(self, addr): + return (addr > ESP8266ROM.IROM_MAP_START) + + def save(self, filename): + total_segments = 0 + with io.BytesIO() as f: # write file to memory first + self.write_common_header(f, self.segments) + + checksum = ESPLoader.ESP_CHECKSUM_MAGIC + + # split segments into flash-mapped vs ram-loaded, and take copies so we can mutate them + flash_segments = [copy.deepcopy(s) for s in sorted(self.segments, key=lambda s:s.addr) if self.is_flash_addr(s.addr) and len(s.data)] + ram_segments = [copy.deepcopy(s) for s in sorted(self.segments, key=lambda s:s.addr) if not self.is_flash_addr(s.addr) and len(s.data)] + + # check for multiple ELF sections that are mapped in the same flash mapping region. + # this is usually a sign of a broken linker script, but if you have a legitimate + # use case then let us know + if len(flash_segments) > 0: + last_addr = flash_segments[0].addr + for segment in flash_segments[1:]: + if segment.addr // self.IROM_ALIGN == last_addr // self.IROM_ALIGN: + raise FatalError(("Segment loaded at 0x%08x lands in same 64KB flash mapping as segment loaded at 0x%08x. " + "Can't generate binary. Suggest changing linker script or ELF to merge sections.") % + (segment.addr, last_addr)) + last_addr = segment.addr + + # try to fit each flash segment on a 64kB aligned boundary + # by padding with parts of the non-flash segments... + while len(flash_segments) > 0: + segment = flash_segments[0] + # remove 8 bytes empty data for insert segment header + if segment.name == '.flash.rodata': + segment.data = segment.data[8:] + # write the flash segment + checksum = self.save_segment(f, segment, checksum) + flash_segments.pop(0) + total_segments += 1 + + # flash segments all written, so write any remaining RAM segments + for segment in ram_segments: + checksum = self.save_segment(f, segment, checksum) + total_segments += 1 + + # done writing segments + self.append_checksum(f, checksum) + image_length = f.tell() + + # kinda hacky: go back to the initial header and write the new segment count + # that includes padding segments. This header is not checksummed + f.seek(1) + try: + f.write(chr(total_segments)) + except TypeError: # Python 3 + f.write(bytes([total_segments])) + + if self.append_digest: + # calculate the SHA256 of the whole file and append it + f.seek(0) + digest = hashlib.sha256() + digest.update(f.read(image_length)) + f.write(digest.digest()) + + with open(filename, 'wb') as real_file: + real_file.write(f.getvalue()) + + def load_extended_header(self, load_file): + def split_byte(n): + return (n & 0x0F, (n >> 4) & 0x0F) + + fields = list(struct.unpack(self.EXTENDED_HEADER_STRUCT_FMT, load_file.read(16))) + + self.wp_pin = fields[0] + + # SPI pin drive stengths are two per byte + self.clk_drv, self.q_drv = split_byte(fields[1]) + self.d_drv, self.cs_drv = split_byte(fields[2]) + self.hd_drv, self.wp_drv = split_byte(fields[3]) + + if fields[15] in [0, 1]: + self.append_digest = (fields[15] == 1) + else: + raise RuntimeError("Invalid value for append_digest field (0x%02x). Should be 0 or 1.", fields[15]) + + # remaining fields in the middle should all be zero + if any(f for f in fields[4:15] if f != 0): + print("Warning: some reserved header fields have non-zero values. This image may be from a newer esptool.py?") + + ESP32ROM.BOOTLOADER_IMAGE = ESP32FirmwareImage @@ -2861,12 +3586,12 @@ class ESP32S3BETA2FirmwareImage(ESP32FirmwareImage): ESP32S3BETA2ROM.BOOTLOADER_IMAGE = ESP32S3BETA2FirmwareImage -class ESP32S3BETA3FirmwareImage(ESP32FirmwareImage): +class ESP32S3FirmwareImage(ESP32FirmwareImage): """ ESP32S3 Firmware Image almost exactly the same as ESP32FirmwareImage """ - ROM_LOADER = ESP32S3BETA3ROM + ROM_LOADER = ESP32S3ROM -ESP32S3BETA3ROM.BOOTLOADER_IMAGE = ESP32S3BETA3FirmwareImage +ESP32S3ROM.BOOTLOADER_IMAGE = ESP32S3FirmwareImage class ESP32C3FirmwareImage(ESP32FirmwareImage): @@ -2885,9 +3610,42 @@ class ESP32C6BETAFirmwareImage(ESP32FirmwareImage): ESP32C6BETAROM.BOOTLOADER_IMAGE = ESP32C6BETAFirmwareImage +class ESP32H2BETA1FirmwareImage(ESP32FirmwareImage): + """ ESP32H2 Firmware Image almost exactly the same as ESP32FirmwareImage """ + ROM_LOADER = ESP32H2BETA1ROM + + +ESP32H2BETA1ROM.BOOTLOADER_IMAGE = ESP32H2BETA1FirmwareImage + + +class ESP32H2BETA2FirmwareImage(ESP32FirmwareImage): + """ ESP32H2 Firmware Image almost exactly the same as ESP32FirmwareImage """ + ROM_LOADER = ESP32H2BETA2ROM + + +ESP32H2BETA2ROM.BOOTLOADER_IMAGE = ESP32H2BETA2FirmwareImage + + +class ESP32C2FirmwareImage(ESP32FirmwareImage): + """ ESP32C2 Firmware Image almost exactly the same as ESP32FirmwareImage """ + ROM_LOADER = ESP32C2ROM + + def set_mmu_page_size(self, size): + if size not in [16384, 32768, 65536]: + raise FatalError("{} is not a valid page size.".format(size)) + self.IROM_ALIGN = size + + +ESP32C2ROM.BOOTLOADER_IMAGE = ESP32C2FirmwareImage + + class ELFFile(object): SEC_TYPE_PROGBITS = 0x01 SEC_TYPE_STRTAB = 0x03 + SEC_TYPE_INITARRAY = 0x0e + SEC_TYPE_FINIARRAY = 0x0f + + PROG_SEC_TYPES = (SEC_TYPE_PROGBITS, SEC_TYPE_INITARRAY, SEC_TYPE_FINIARRAY) LEN_SEC_HEADER = 0x28 @@ -2944,7 +3702,7 @@ class ELFFile(object): name_offs, sec_type, _flags, lma, sec_offs, size = struct.unpack_from("<LLLLLL", section_header[offs:]) return (name_offs, sec_type, lma, size, sec_offs) all_sections = [read_section_header(offs) for offs in section_header_offsets] - prog_sections = [s for s in all_sections if s[1] == ELFFile.SEC_TYPE_PROGBITS] + prog_sections = [s for s in all_sections if s[1] in ELFFile.PROG_SEC_TYPES] # search for the string table section if not (shstrndx * self.LEN_SEC_HEADER) in section_header_offsets: @@ -3012,13 +3770,17 @@ def slip_reader(port, trace_function): """ partial_packet = None in_escape = False + successful_slip = False while True: waiting = port.inWaiting() read_bytes = port.read(1 if waiting == 0 else waiting) if read_bytes == b'': - waiting_for = "header" if partial_packet is None else "content" - trace_function("Timed out waiting for packet %s", waiting_for) - raise FatalError("Timed out waiting for packet %s" % waiting_for) + if partial_packet is None: # fail due to no data + msg = "Serial data stream stopped: Possible serial noise or corruption." if successful_slip else "No serial data received." + else: # fail during packet transfer + msg = "Packet content transfer stopped (received {} bytes)".format(len(partial_packet)) + trace_function(msg) + raise FatalError(msg) trace_function("Read %d bytes: %s", len(read_bytes), HexFormatter(read_bytes)) for b in read_bytes: if type(b) is int: @@ -3030,7 +3792,7 @@ def slip_reader(port, trace_function): else: trace_function("Read invalid data: %s", HexFormatter(read_bytes)) trace_function("Remaining data in serial buffer: %s", HexFormatter(port.read(port.inWaiting()))) - raise FatalError('Invalid head of packet (0x%s)' % hexify(b)) + raise FatalError('Invalid head of packet (0x%s): Possible serial noise or corruption.' % hexify(b)) elif in_escape: # part-way through escape sequence in_escape = False if b == b'\xdc': @@ -3047,6 +3809,7 @@ def slip_reader(port, trace_function): trace_function("Received full packet: %s", HexFormatter(partial_packet)) yield partial_packet partial_packet = None + successful_slip = True else: # normal byte in packet partial_packet += b @@ -3055,6 +3818,15 @@ def arg_auto_int(x): return int(x, 0) +def format_chip_name(c): + """ Normalize chip name from user input """ + c = c.lower().replace('-', '') + if c == 'esp8684': # TODO: Delete alias, ESPTOOL-389 + print('WARNING: Chip name ESP8684 is deprecated in favor of ESP32-C2 and will be removed in a future release. Using ESP32-C2 instead.') + return 'esp32c2' + return c + + def div_roundup(a, b): """ Return a/b rounded up to nearest integer, equivalent result to int(math.ceil(float(int(a)) / float(int(b))), only @@ -3135,7 +3907,7 @@ def pad_to(data, alignment, pad_character=b'\xFF'): class FatalError(RuntimeError): """ Wrapper class for runtime errors that aren't caused by internal bugs, but by - ESP8266 responses or input content. + ESP ROM responses or input content. """ def __init__(self, message): RuntimeError.__init__(self, message) @@ -3144,9 +3916,37 @@ class FatalError(RuntimeError): def WithResult(message, result): """ Return a fatal error object that appends the hex values of - 'result' as a string formatted argument. + 'result' and its meaning as a string formatted argument. """ - message += " (result was %s)" % hexify(result) + + err_defs = { + 0x101: 'Out of memory', + 0x102: 'Invalid argument', + 0x103: 'Invalid state', + 0x104: 'Invalid size', + 0x105: 'Requested resource not found', + 0x106: 'Operation or feature not supported', + 0x107: 'Operation timed out', + 0x108: 'Received response was invalid', + 0x109: 'CRC or checksum was invalid', + 0x10A: 'Version was invalid', + 0x10B: 'MAC address was invalid', + # Flasher stub error codes + 0xC000: 'Bad data length', + 0xC100: 'Bad data checksum', + 0xC200: 'Bad blocksize', + 0xC300: 'Invalid command', + 0xC400: 'Failed SPI operation', + 0xC500: 'Failed SPI unlock', + 0xC600: 'Not in flash mode', + 0xC700: 'Inflate error', + 0xC800: 'Not enough data', + 0xC900: 'Too much data', + 0xFF00: 'Command not implemented', + } + + err_code = struct.unpack(">H", result[:2]) + message += " (result was {}: {})".format(hexify(result), err_defs.get(err_code[0], 'Unknown result')) return FatalError(message) @@ -3273,7 +4073,7 @@ def _update_image_flash_params(esp, address, args, image): flash_freq = flash_size_freq & 0x0F if args.flash_freq != 'keep': - flash_freq = {'40m': 0, '26m': 1, '20m': 2, '80m': 0xf}[args.flash_freq] + flash_freq = esp.parse_flash_freq_arg(args.flash_freq) flash_size = flash_size_freq & 0xF0 if args.flash_size != 'keep': @@ -3333,7 +4133,7 @@ def write_flash(esp, args): argfile.seek(0, os.SEEK_END) if address + argfile.tell() > flash_end: raise FatalError(("File %s (length %d) at offset %d will not fit in %d bytes of flash. " - "Use --flash-size argument, or change flashing address.") + "Use --flash_size argument, or change flashing address.") % (argfile.name, argfile.tell(), address, flash_end)) argfile.seek(0) @@ -3497,8 +4297,20 @@ def write_flash(esp, args): def image_info(args): + if args.chip == "auto": + print("WARNING: --chip not specified, defaulting to ESP8266.") image = LoadFirmwareImage(args.chip, args.filename) print('Image version: %d' % image.version) + if args.chip != 'auto' and args.chip != 'esp8266': + print( + "Minimal chip revision:", + "v{}.{},".format(image.min_rev_full // 100, image.min_rev_full % 100), + "(legacy min_rev = {})".format(image.min_rev) + ) + print( + "Maximal chip revision:", + "v{}.{}".format(image.max_rev_full // 100, image.max_rev_full % 100), + ) print('Entry point: %08x' % image.entrypoint if image.entrypoint != 0 else 'Entry point not set') print('%d segments' % len(image.segments)) print() @@ -3539,9 +4351,10 @@ def make_image(args): def elf2image(args): e = ELFFile(args.input) if args.chip == 'auto': # Default to ESP8266 for backwards compatibility - print("Creating image for ESP8266...") args.chip = 'esp8266' + print("Creating {} image...".format(args.chip)) + if args.chip == 'esp32': image = ESP32FirmwareImage() if args.secure_pad: @@ -3556,8 +4369,8 @@ def elf2image(args): image = ESP32S3BETA2FirmwareImage() if args.secure_pad_v2: image.secure_pad = '2' - elif args.chip == 'esp32s3beta3': - image = ESP32S3BETA3FirmwareImage() + elif args.chip == 'esp32s3': + image = ESP32S3FirmwareImage() if args.secure_pad_v2: image.secure_pad = '2' elif args.chip == 'esp32c3': @@ -3568,21 +4381,43 @@ def elf2image(args): image = ESP32C6BETAFirmwareImage() if args.secure_pad_v2: image.secure_pad = '2' + elif args.chip == 'esp32h2beta1': + image = ESP32H2BETA1FirmwareImage() + if args.secure_pad_v2: + image.secure_pad = '2' + elif args.chip == 'esp32h2beta2': + image = ESP32H2BETA2FirmwareImage() + if args.secure_pad_v2: + image.secure_pad = '2' + elif args.chip == 'esp32c2': + image = ESP32C2FirmwareImage() + if args.secure_pad_v2: + image.secure_pad = '2' elif args.version == '1': # ESP8266 image = ESP8266ROMFirmwareImage() - else: + elif args.version == '2': image = ESP8266V2FirmwareImage() + else: + image = ESP8266V3FirmwareImage() image.entrypoint = e.entrypoint image.flash_mode = {'qio': 0, 'qout': 1, 'dio': 2, 'dout': 3}[args.flash_mode] if args.chip != 'esp8266': - image.min_rev = int(args.min_rev) + image.min_rev = args.min_rev + image.min_rev_full = args.min_rev_full + image.max_rev_full = args.max_rev_full + + if args.flash_mmu_page_size: + image.set_mmu_page_size(flash_size_bytes(args.flash_mmu_page_size)) # ELFSection is a subclass of ImageSegment, so can use interchangeably image.segments = e.segments if args.use_segments else e.sections - image.flash_size_freq = image.ROM_LOADER.FLASH_SIZES[args.flash_size] - image.flash_size_freq += {'40m': 0, '26m': 1, '20m': 2, '80m': 0xf}[args.flash_freq] + if args.pad_to_size: + image.pad_to_size = flash_size_bytes(args.pad_to_size) + + image.flash_size_freq = image.ROM_LOADER.parse_flash_size_arg(args.flash_size) + image.flash_size_freq += image.ROM_LOADER.parse_flash_freq_arg(args.flash_freq) if args.elf_sha256_offset: image.elf_sha256 = e.sha256() @@ -3600,6 +4435,8 @@ def elf2image(args): args.output = image.default_output_name(args.input) image.save(args.output) + print("Successfully created {} image.".format(args.chip)) + def read_mac(esp, args): mac = esp.read_mac() @@ -3716,15 +4553,23 @@ def write_flash_status(esp, args): def get_security_info(esp, args): - (flags, flash_crypt_cnt, key_purposes) = esp.get_security_info() - # TODO: better display - print('Flags: 0x%08x (%s)' % (flags, bin(flags))) - print('Flash_Crypt_Cnt: 0x%x' % flash_crypt_cnt) - print('Key_Purposes: %s' % (key_purposes,)) + si = esp.get_security_info() + # TODO: better display and tests + print('Flags: {:#010x} ({})'.format(si["flags"], bin(si["flags"]))) + print('Flash_Crypt_Cnt: {:#x}'.format(si["flash_crypt_cnt"])) + print('Key_Purposes: {}'.format(si["key_purposes"])) + if si["chip_id"] is not None and si["api_version"] is not None: + print('Chip_ID: {}'.format(si["chip_id"])) + print('Api_Version: {}'.format(si["api_version"])) def merge_bin(args): - chip_class = _chip_to_rom_loader(args.chip) + try: + chip_class = _chip_to_rom_loader(args.chip) + except KeyError: + msg = "Please specify the chip argument" if args.chip == "auto" else "Invalid chip choice: '{}'".format(args.chip) + msg = msg + " (choose from {})".format(', '.join(SUPPORTED_CHIPS)) + raise FatalError(msg) # sort the files by offset. The AddrFilenamePairAction has already checked for overlap input_files = sorted(args.addr_filename, key=lambda x: x[0]) @@ -3772,12 +4617,12 @@ def main(argv=None, esp=None): external_esp = esp is not None - parser = argparse.ArgumentParser(description='esptool.py v%s - ESP8266 ROM Bootloader Utility' % __version__, prog='esptool') + parser = argparse.ArgumentParser(description='esptool.py v%s - Espressif chips ROM Bootloader Utility' % __version__, prog='esptool') parser.add_argument('--chip', '-c', help='Target chip type', - type=lambda c: c.lower().replace('-', ''), # support ESP32-S2, etc. - choices=['auto', 'esp8266', 'esp32', 'esp32s2', 'esp32s3beta2', 'esp32s3beta3', 'esp32c3', 'esp32c6beta'], + type=format_chip_name, # support ESP32-S2, etc. + choices=['auto'] + SUPPORTED_CHIPS, default=os.environ.get('ESPTOOL_CHIP', 'auto')) parser.add_argument( @@ -3873,12 +4718,12 @@ def main(argv=None, esp=None): extra_fs_message = "" parent.add_argument('--flash_freq', '-ff', help='SPI Flash frequency', - choices=extra_keep_args + ['40m', '26m', '20m', '80m'], + choices=extra_keep_args + ['80m', '60m', '48m', '40m', '30m', '26m', '24m', '20m', '16m', '15m', '12m'], default=os.environ.get('ESPTOOL_FF', 'keep' if allow_keep else '40m')) parent.add_argument('--flash_mode', '-fm', help='SPI Flash mode', choices=extra_keep_args + ['qio', 'qout', 'dio', 'dout'], default=os.environ.get('ESPTOOL_FM', 'keep' if allow_keep else 'qio')) - parent.add_argument('--flash_size', '-fs', help='SPI Flash size in MegaBytes (1MB, 2MB, 4MB, 8MB, 16M)' + parent.add_argument('--flash_size', '-fs', help='SPI Flash size in MegaBytes (1MB, 2MB, 4MB, 8MB, 16MB, 32MB, 64MB, 128MB)' ' plus ESP8266-only (256KB, 512KB, 2MB-c1, 4MB-c1)' + extra_fs_message, action=FlashSizeAction, auto_detect=auto_detect, default=os.environ.get('ESPTOOL_FS', 'keep' if allow_keep else '1MB')) @@ -3935,8 +4780,36 @@ def main(argv=None, esp=None): help='Create an application image from ELF file') parser_elf2image.add_argument('input', help='Input ELF file') parser_elf2image.add_argument('--output', '-o', help='Output filename prefix (for version 1 image), or filename (for version 2 single image)', type=str) - parser_elf2image.add_argument('--version', '-e', help='Output image version', choices=['1', '2'], default='1') - parser_elf2image.add_argument('--min-rev', '-r', help='Minimum chip revision', choices=['0', '1', '2', '3'], default='0') + parser_elf2image.add_argument('--version', '-e', help='Output image version', choices=['1', '2', '3'], default='1') + parser_elf2image.add_argument( + # kept for compatibility + # Minimum chip revision (deprecated, consider using --min-rev-full) + "--min-rev", + "-r", + # In v3 we do not do help=argparse.SUPPRESS because + # it should remain visible. + help="Minimal chip revision (ECO version format)", + type=int, + choices=range(256), + metavar="{0, ... 255}", + default=0, + ) + parser_elf2image.add_argument( + "--min-rev-full", + help="Minimal chip revision (in format: major * 100 + minor)", + type=int, + choices=range(65536), + metavar="{0, ... 65535}", + default=0, + ) + parser_elf2image.add_argument( + "--max-rev-full", + help="Maximal chip revision (in format: major * 100 + minor)", + type=int, + choices=range(65536), + metavar="{0, ... 65535}", + default=65535, + ) parser_elf2image.add_argument('--secure-pad', action='store_true', help='Pad image so once signed it will end on a 64KB boundary. For Secure Boot v1 images only.') parser_elf2image.add_argument('--secure-pad-v2', action='store_true', @@ -3946,7 +4819,12 @@ def main(argv=None, esp=None): type=arg_auto_int, default=None) parser_elf2image.add_argument('--use_segments', help='If set, ELF segments will be used instead of ELF sections to genereate the image.', action='store_true') - + parser_elf2image.add_argument('--flash-mmu-page-size', help="Change flash MMU page size.", choices=['64KB', '32KB', '16KB']) + parser_elf2image.add_argument( + "--pad-to-size", + help="The block size with which the final binary image after padding must be aligned to. Value 0xFF is used for padding, similar to erase_flash", + default=None, + ) add_spi_flash_subparsers(parser_elf2image, allow_keep=False, auto_detect=False) subparsers.add_parser( @@ -4024,11 +4902,10 @@ def main(argv=None, esp=None): help='Address followed by binary filename, separated by space', action=AddrFilenamePairAction) - subparsers.add_parser( - 'version', help='Print esptool version') - subparsers.add_parser('get_security_info', help='Get some security-related data') + subparsers.add_parser('version', help='Print esptool version') + # internal sanity check - every operation matches a module function of the same name for operation in subparsers.choices.keys(): assert operation in globals(), "%s should be a module function" % operation @@ -4074,6 +4951,7 @@ def main(argv=None, esp=None): esp = esp or get_default_connected_device(ser_list, port=args.port, connect_attempts=args.connect_attempts, initial_baud=initial_baud, chip=args.chip, trace=args.trace, before=args.before) + if esp is None: raise FatalError("Could not connect to an Espressif device on any of the %d available serial ports." % len(ser_list)) @@ -4089,6 +4967,9 @@ def main(argv=None, esp=None): if esp.secure_download_mode: print("WARNING: Stub loader is not supported in Secure Download Mode, setting --no-stub") args.no_stub = True + elif not esp.IS_STUB and esp.stub_is_disabled: + print("WARNING: Stub loader has been disabled for compatibility, setting --no-stub") + args.no_stub = True else: esp = esp.run_stub() @@ -4112,11 +4993,84 @@ def main(argv=None, esp=None): # ROM loader doesn't enable flash unless we explicitly do it esp.flash_spi_attach(0) + # XMC chip startup sequence + XMC_VENDOR_ID = 0x20 + + def is_xmc_chip_strict(): + id = esp.flash_id() + rdid = ((id & 0xff) << 16) | ((id >> 16) & 0xff) | (id & 0xff00) + + vendor_id = ((rdid >> 16) & 0xFF) + mfid = ((rdid >> 8) & 0xFF) + cpid = (rdid & 0xFF) + + if vendor_id != XMC_VENDOR_ID: + return False + + matched = False + if mfid == 0x40: + if cpid >= 0x13 and cpid <= 0x20: + matched = True + elif mfid == 0x41: + if cpid >= 0x17 and cpid <= 0x20: + matched = True + elif mfid == 0x50: + if cpid >= 0x15 and cpid <= 0x16: + matched = True + return matched + + def flash_xmc_startup(): + # If the RDID value is a valid XMC one, may skip the flow + fast_check = True + if fast_check and is_xmc_chip_strict(): + return # Successful XMC flash chip boot-up detected by RDID, skipping. + + sfdp_mfid_addr = 0x10 + mf_id = esp.read_spiflash_sfdp(sfdp_mfid_addr, 8) + if mf_id != XMC_VENDOR_ID: # Non-XMC chip detected by SFDP Read, skipping. + return + + print("WARNING: XMC flash chip boot-up failure detected! Running XMC25QHxxC startup flow") + esp.run_spiflash_command(0xB9) # Enter DPD + esp.run_spiflash_command(0x79) # Enter UDPD + esp.run_spiflash_command(0xFF) # Exit UDPD + time.sleep(0.002) # Delay tXUDPD + esp.run_spiflash_command(0xAB) # Release Power-Down + time.sleep(0.00002) + # Check for success + if not is_xmc_chip_strict(): + print("WARNING: XMC flash boot-up fix failed.") + print("XMC flash chip boot-up fix successful!") + + # Check flash chip connection + if not esp.secure_download_mode: + try: + flash_id = esp.flash_id() + if flash_id in (0xffffff, 0x000000): + print('WARNING: Failed to communicate with the flash chip, read/write operations will fail. ' + 'Try checking the chip connections or removing any other hardware connected to IOs.') + except Exception as e: + esp.trace('Unable to verify flash chip connection ({}).'.format(e)) + + # Check if XMC SPI flash chip booted-up successfully, fix if not + if not esp.secure_download_mode: + try: + flash_xmc_startup() + except Exception as e: + esp.trace('Unable to perform XMC flash chip startup sequence ({}).'.format(e)) + if hasattr(args, "flash_size"): print("Configuring flash size...") detect_flash_size(esp, args) if args.flash_size != 'keep': # TODO: should set this even with 'keep' esp.flash_set_parameters(flash_size_bytes(args.flash_size)) + # Check if stub supports chosen flash size + if esp.IS_STUB and args.flash_size in ('32MB', '64MB', '128MB'): + print("WARNING: Flasher stub doesn't fully support flash size larger than 16MB, in case of failure use --no-stub.") + + if esp.IS_STUB and hasattr(args, "address") and hasattr(args, "size"): + if args.address + args.size > 0x1000000: + print("WARNING: Flasher stub doesn't fully support flash size larger than 16MB, in case of failure use --no-stub.") try: operation_func(esp, args) @@ -4281,292 +5235,407 @@ class AddrFilenamePairAction(argparse.Action): # Binary stub code (see flasher_stub dir for source & details) ESP8266ROM.STUB_CODE = eval(zlib.decompress(base64.b64decode(b""" -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\ +5dwuzr3/OyU1/H+n+L8kk1ilxnz4f3giyVw=\ """))) ESP32ROM.STUB_CODE = eval(zlib.decompress(base64.b64decode(b""" -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\ """))) ESP32S2ROM.STUB_CODE = eval(zlib.decompress(base64.b64decode(b""" -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\ ++rqe/Hebvj3Y4S8jQkWaGpP//j8geRE3\ """))) ESP32S3BETA2ROM.STUB_CODE = eval(zlib.decompress(base64.b64decode(b""" -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\ """))) -ESP32S3BETA3ROM.STUB_CODE = eval(zlib.decompress(base64.b64decode(b""" -eNqVW3l33LYR/yqrlXXZznsAuUsCalJLcbqWczSSD1l21FeDIBm7dV1Z3jzLitLPXswFgNxVk/6xMgnimBnM/OYA/OvOsrta7uxPmp3zK2XCT8Hv9fmV9tkLPfCLm907v/LNg9AnNVeH8M/G+VXvwq8PHdQEWmDK\ -Inzr7aB5N/yZTcKjnYVfWKorQksVfvN8NRg4p4FGh3+rwSSBFJg+zGAMUe+gTS3DdCpjp5n2QEVorUNXmGMG8wCxejChpW66Da2Rhmf46fWznM9AM4xsR+QEMsLaBp7UndMj+oo93R/pOVwXfvcncQMmK1sBHAo5\ -HUjJC1cNzac8CSGtygwiSU0mVzsizxZv6CG1oIRPP6/yEWa8Ca0FsDJVsKewI6u8wO+A6O2E2NAvbId1iZSuzaTmx2TZEUNDqsZrWv4XdRXGgby9eyCCTz3NLH8/OJCnI1iLx4Cm8mwi9QaINLD4RPQu7EPb8/KB\ -tc6RSufaa7PnqLCWuTG50JtiL9O2koez2Ac9LaitSWzDD7Rch132lrngtjjIF2c8IlBpm9yuisfj/c8WwH1yiRpZTKSKzzVs14I7zxLnHduzg2fD7Y6YT5SheTTZUJwv6IOGOUGHlbqhCeCLDhN0egGDcoXLFf99\ -Lld7vozTDNvfD0ctieg2IxRV3sOXTDgsyLEGdkZg6OAdERdUq0mNFckHu/vvyGKBF5MZaIKy++FPTTCpbx1lxqMunj1MUIBdw/gWqJ7TPGByIApjD+GPho3bsPQNEUPDwownpgXXcJ9XdjC0euvenvBG9+PVBx2H\ -qpM6vSpfTY+/IQ3tgLuw6U3NVK8A8v8SWleuEoCerOKfHzizFdG4oWiIYzsQUuji7AkICcW1QUKC3u1YWN4ziYa/DkkjloH5xLZvb2M7Q6s1bUozGgre9X36YYeZeBBtoiqSHkdcjp5quskoDlQV0MfNiXLB7MYx\ -pBoWIfgQIRxM0sF7AHpTbLLTYdzSjCaOqTYqgJwpJ5NlR2EAogPgKBtAYzYZOpCdQLZpWDtrsNl7hAwwZ9+ElwaRM8xRAkXfi6Yc8BfG7YHWNLdoTbEgJqKUcbp8pFmn8CC0BUCJYHeLlJ9Os5Yug0E99qqIQ0b3\ -/6f/o92E5c2E1NKWX8R9mpD8PS7wEF427+7ubSY9a0UxKsfBV4uUnc2OS8YNCzJtzkBXXz09Pj8/pICK5LMlkdWboNiuohFq9ubOz7ShECOgg5yNff8VTBiefA1v6gVoRfjTVP/irVSkj7no7YqpXxwvWbUQHo7f\ -ArNh7aZksYFtheeGbRlBiQEVTRasw1cQwjTF5CJ0qCdPArnssSya+f5zYoI09fjsMEOQLH4FbfcWRF64tHmgtGhJvOPoS8BHtxUZAzyLczcYF4DF6H8ALa9JFkEyBrgGWkvgcCtZ5ggPf11VTjOKsW+TCSLWbHV8\ -DFiM35/Sq5/tPduFDdyf7sE/uzOYyKuCpx7C2AWLzmFMNoxqMe56Q4ZzsOR/SRm3YGeAnCxc1WjKE6ConLD7W/EtP+A0aJKh2RNykdxZWYq0Y4JP5PY6iPyymK1IJivPo4De4dNkJYZoeD3HIrnNVzp1B7BwFfpx\ -c/voAQ9TIGZEOzNItcURdACm/KFE80WeimCL3jMY9UAK5qZ3S/WlKHOxd2aPOokmvkA0Qq2SLZ+PSTxi9RdXAorPfRXzzY4BI0lNcofvAPPr452sj18wAIxdYT4JT45wl8XYaydsuc8aFSfGiI39gvRO6TRewkbd\ -TDmcRcp59X52u+NO+fJZ/gImRCaLJhBCXvHPEIvE9tbHl2pjg2gBgNbqNt1nc48O7CRPQl4hfj30+yRFBIIZA4GGfKaYiIclSABWvbikTCZxL3RKqNdCRu/vjtdZ5aLrxjN8Oz1AnDvdpJQQXWbNsUE2GmTjxC/b\ -NXRk6QR69yaNwW2sSNnRQtUKg6j6ft3misa7TOOBCF/fplwy8l2uBm/yl4v85Sp/WQ5Vp8OkCLXidbRBslxurg43nIDmfTBlDUhi1Evo3oj8njB8qCd9lk2scPBBMPERh+hYt3nAAsiifL9mBxLsfSDCe1nLXBM6\ -xQin+gJd+EfGD70NnS+WA22tkxahnrrzpegt6ipjNMqkSBKLYKRp/K36av2r5NxNgRHLb/Rms7ANXWWbmAEejCN9cwj/wQov8yAJIfsCBDhhcsDXSF6EoRLrqfDJrg6qbxW7ukaNSb+eXj8HOcwJoJEulMHlNlGm\ -2fVRueziF1bVuiZ4B7CQXYnuBKP1xTti2bfJ8yKbqPV7aXf9ijxPAxHAWEdht2UAQSCt3hFNTUNcGw6VQO1AGhpCvOpRAgc/z3VGGLmmMCkWLXTzaSFQdENYYGsKNb0CxVWGJFKLDn8iqdmOds2y5jSszzmy2PmY\ -wy5JDQabbh3EdElfMABsojXiXprfQwosQv14dPgYip5Sf8XSp64PKHU0qj7IM9zX5oCjq0EeOEoaV9pmrAwDv3Ww2jFNuuYjVszEYeNL2MKdbMZZVlDu1ZDEjEGTzdDk5TwkrxaPqSLYsZts+cXNOIrEVWIFJnlY\ -79Nj6oCkCZQ6k73Y1N+kWZxkTruxtMPlRxnXpknCX6+5Wt66WKp9P8WU+utkdwiXfqwZG5KRaEobIsThnF838Pe7aV2wGc1TrkmVGEBPPdmS8H5Lvh/+BFa6gX/B1LDx7p0Yyz4m1GyilQdCALbAslHp8PfKJnBt\ -V8Ag+oUnnKTpwYlAGQzieJrC5z2Jln9Z70vaao1wfLFy0lBQKQYif7BKX4Iz1/AHO0vxB5IIKBqYkrnhyNSvidRB+g1HrK3+kpfMGpvsuYs5Ke00T+vWUN9Y3lpMjLu734CDLu+l2kp2roIFmo8Dbq+yPLkcyHEG\ -pzAa/WDNyuJ3sQ0wqzg5X+6dbHOCDw7HS2m1pG1GFUMqZYqSXLHiKMBgrocFnXLSP4RnAyafEVzak8UQdbTfhKW54KPSuQWUeEhVCf27uaD/H1meoZr1L9Wla4JaXQyDNZxcJccE7RCHuqwd++CEC6nnpBQkiZTq\ -fOhuDBNWEeXYt7an/RuJmth2YIHqatJzfwitbSXtkFeZflemuhFsghbVH3GNyqHj7M/SzFiLVIs7qSRlR3TBOujgiC4MC6/Sp5amlCMeWFyK85UArPx6mXXBNU9eshksuSCBCrYSYIMKjAhTVReputtz8oxUXcbS\ -3JJpFuDp7d84xR1I/mVGsUp1Pb2y4nVUx0FzOkPCRKpJZyGYRBZjiT4fiDPrqainNj9ljVoa33JCNUpwY8dyzNe/s/KFIiQfdvgnzMAi6bgXaS5q1v2j0TeKA8naowqViB0nEgnWElzDLuLRV08tW/IKMV43eMjK\ -d2Zo0KopOyrN9f0pn261iz+NQgnYrPnpU4kA15UI1FNA7MWfx0HINgO5wCFjGLnes/MdU2Ohf5LOsJrZKZfpuy0MHYC0a7JSn+0/uZ89+HAc+vnDFwwrlJC8X6kT79IcK4l5yXAKku4Vlxq1WsgnGPvjmsiwSjE1\ -xuztMLzzgv6QimH7sRzSnb6kHNpnZyEQ2Gsr3EC3+QuyXZAZMeSzIqzLi/ea2B6zN1Znqws5QQNlhkMSenvOfQvh98F6fm3Gb+tj4rWDlRdIGOf780VmohqozvfH8rnFuv0BOBlyELcgbgrTWY5rHno9vZ1LeuO7\ -fH9qyiE8frzgJD6aoWgtOw7TZoaMG8ZzmoZ1HC0I5qs800t703Pdu0ET3CHSHT0V7NvoBPoJLYknXHNO4GeZ/XW8iuCCKVIEptARmkdjOCnqhNum+g8fr5oXGCrvkDe1Jrl70DTHggjGfL0GBgxydN88Io1VSP/a\ -da3Q+oucktSDxS1CRkFl3k5vkb+lgLqlax3g9Dq959E4773c5NJukYnKcAIbg1FgpxU3VCR1iMThichjPq02Z7jKTkFl/F5uU/gM+D0HLpjBlnJcJzVPqKSYUW0DTydr3Oj3dGAPmAmun0rTqt6j4rWVqGUuD3yA\ -T8cU+DCRT5UooMRYiJhl/F5z+IU+KYRfkRXhQzMfJfFhqmeSDahMaKhY5TqhHXGGi8KXL+YULwog1JnioZy4PwWCf8hDTBiYmRDoWu7rfPVYxv4M2cAn2MsXMDfUuPAsE9XGSM2nW3Pe1/0NBJ5UGKb9eo0Wd9sP\ -ZdUH8Q7TTjp/iZo0XqRiF6a4sm/UbMWHn3K01qlnhwQRtv4K5rgh1M/rUFk9DA8KLW0TBGMtAzu6pREdeE1Dk+Cxn9mjDBk8SBsPQbAF4CmMu8yOQzp8xYAcA+/8zAWGufzsxFnorAF7QF09BlUvSlpcxcPD/mHC\ -S8KUFcHkcfAAz3y1wZqI5bQOZnhBhsnFyBKqeD07H0sKKpcBVBYJQwTkmpQOg+0hnU20cMpIVMFitCVbeMFWIdY3Dtx8dcrEzE8YuLvsq+XqwhyH1V99z8IEi8njO6Sqp+pOa9ifjapCUIGjSPLtd7Q1Fs+ikXBO\ -pYB7z32dB+cLLZpD+pYrotYSmXgPoCEMo8ru5xz2ez7vqqW01vGtmZKPnHJODRcEyHNrPtBiDXVod3txh96LFOOdQDyJhbuG8xs8+8D27ibld0Zq1gVohT+lYl5TjjK8PqbGh8C9niUN/BXTIT92lttlSqx8lSID\ -3QtG7fL5VeyIvquW0gQrVN5FVV+lUNKrlEwOzsUKIfXHrHEmjR9DnNP44VByRIB6+hN8OUPss4R9eOK4Agua4yeLhZpumPOLM1VLzKajB4EHhUmFkVyeKvPHZJ4e0fB6FYIc3+3Ay19E4ypWRlNkjlqKfOhYYIeP\ -oFJchPaOJeiCEyWXpedNrDkMCg5U4EFn2TQT6uYbsnRMdWratW7+MpXpMMDu/mgR4+2YuxPMk3JIgQPgIE2+oIHcMBDqOV+tK9lje7muodOVBK+77zkYZ2h3xRYH+lz6xmezxZGFJXvv5vdXb3DSUAyWSxEIC8Hg\ -gWM3jpS/hYdLuUjw+Tkcdc33P32Gf67mIBevLB5mPFsTo0OcKptkMpna0T6hj9Fyxg+XOiJZBcF0LwAJJ81klvormPwDnRHgwaPos/0ABH8C2j67q20BpE/poF/hQRUbjUMxb/2FZYRi/lBl8KgT5jnNINi0UV0p\ -Z/YUuOGzoUiVLmocrEm/MMtpzDYs90EskKTcNAQXjd2WqJAbhARIypzhMw/oDvjtPHt2P+EQu+QGENb8Ik0M6ScoBGgpnstn4RywCsUQOA8xDPFwbh2vnbDIfMDqS1Cyx1zejOEGfL/0clsNpOiveCIn4XPPkaXl\ -m69ttcdHUXhuXFEZxHk5E8e7xuJkavt8kSGh/5Kul6EqVqyEaGmcyMUqfZzB2LPFiSjmCR9Gx682zP9X+Rpw55SD4vxYUvs1sKdTzRD7zNZUFWIqPzCURVwOtL4qyXEOFpz9Hs6y6xv1wWfJiSGiRekMr2vKjfk2\ -RfkUkuyyOdUc3cA++yLd1jGVnPHEaykw4ib73wI9Z8EaDX57yka9LlPPb4oouPZG6wIRfioT93Lfo145+se6w046oiYqcaDcscuWjcu1cjleuKwGQ6eJKhLbzv0J/p+Mv39cukv4nxla1XVZW1UX4Uv3fnn5WRqN\ -sjMVGlu3dKP/wuHdgx3+kk8UwkM7t8Vv/wXDV5Qe\ +ESP32S3ROM.STUB_CODE = eval(zlib.decompress(base64.b64decode(b""" +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\ """))) ESP32C3ROM.STUB_CODE = eval(zlib.decompress(base64.b64decode(b""" -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\ +2JPh/i7GZklhkqv/AdnBtYo=\ """))) ESP32C6BETAROM.STUB_CODE = eval(zlib.decompress(base64.b64decode(b""" -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\ -TB3fMw==\ +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\ +"""))) +ESP32H2BETA1ROM.STUB_CODE = eval(zlib.decompress(base64.b64decode(b""" +eNrFWmtbG8cV/isYMCRunnZG2ts4MUi2QFyM66SOKa7cend2lzoXnoBFjNvov3fec9GsAMn+1g8CaXZ35sw573nPZfa/29PmZrr9eK3antwYO7mx4VPl4Ts+5t3R5MYX4Vt/clO6yU1Bo1thsHwR/mQ/hD9JGMrC\ +/2Y9/PHydEJPT27a+iynOXbDH/M8zN+fhtFkd3I1uWlM+NobVKONMHuxyQJUvcPJTd0bPT1cDw+atAyr9sIn3FsUg/CnP9meXGB6THYdZkjDj5bvcvksjIbFmyCwdeFLG674IHnV5pNtEuqP43BfHe6v+Nm2zfMl\ +F3TpIeuFthk+dZ3TNnk+CJ+JwnpzzYVP0JsLH9/H/2czkaU4xR4HEH4vrmPC/8INWQX3L+p2Z7I0rzDoyKArxd/WP4P2efaw3t2pYa4kH0E9wTiuz/MGcwYDOX+sK8PY4aEyPOzbt7llddo+G5lvt8VBWKQeh7nt\ +KECgDk82qoZkhGuwHq9SJWwiY06wbroLYUddxVme32F9GgtT5mEbTcY3EMIyxgR+O8dY0nGeRHXxkk2AJfG/yrfmKntKRhkGuZ3lKUw6xq3YTgk4QTbGOBYpGpmnIJ2uh5253mhBtaIa3YqHLofhrw2galIeLSyr\ +p8BiDvPbB/AAIMkwFnBb6053cFOcOkjn2QlVu4Xbx0hQby0jbX3Khoc2oG+by5Q2ylYZ+J+3MlmfoQb52gaTjtVpOsAIj2AfmXicEczZeIvuuLVxddijptVIMFoq7KbGUv7uFBDsV164yvcIm+IHuKNI9wA3Bub3\ +u/5Qdpbh7nEHydiVVxX1Ow4qXFbjbvzoMMhT3DLk+61ITrozuJLhSm8DBpepiwwA2eBFSt1+d3Jnz6M4tHwSF4Eug65bpQ3MSTSpNlL5S8yiaDS3NqOQp50v1wH9FiIxCZaeu5PQF29JEFjrD2yWl3rAVwIm2YhG\ +cAAj3qYXmbLuTtmIwLUgk2MNhYx7OYhiDL6ONbxQGHnBe+jSDwAHthBQlgBwuFQ5wmzQ2NcjuYJQ4rbYlW3/jDFOccQf+B/DNEZ/jvy/sFeYBnHkCTHvwWBLtNiwWzrRLugANEs+bZhIGn8rPGHDPtVNDshTJvTs\ +ZMD6CtNNSOYJGLPP23D5YMQERJFSQqOEyW50vEob9huN4p2gfP2txGTf7P6ioVN8R2Lksr3QfirhKpsySVS9MW+E0Juv7bBVGvttNwCNFZ6D+5cEBxStMCx8v7e3TI618AV8FAIcU6RmDkKQHUUMNYdRXyBKZ8rY\ +YApXLHa8ZMriAbK+y27EneczZgJkNwghXq4AIEX9mLEHdOF/ZZngC9xcbfAFZschwoljwmNlvGTCvDc+x9C/GFEALCPBpBe1ypS+zIwYq/vxbk93x3WtcAs/d94hs97tqEEUXsRosXJJG5fkKADtVnlMQCS94K0+\ +EtcV765SCf6QziUS/JfFXYN8yshdjLM9BDcinOtL0IedfcCl91g53RJjhi3UDW+0ltylLkWifAZglJwfCD1NWRmF5gciLARvyx0WlKlmncGpjLQM2tu3pzbpT3F+IoJyPGRG4xzlvBNz6QMxneiZBVkioBMBFx/v\ +rAaDpuur+O3dplz2dg9f4GL4ZlIfdVWVg0ewUjmaXASiarOz9g10++Zw7qggU+c/4Ca12fsjJljYoEhfrGbZco6vWyzb1AvTrH9+GkRDRBvnlHg3h++ZvRi9kC77kfl1TmOS7dzrOoQse3k5+/AaNPAGXPRPAKoS\ +gqH4uA4S3IJnHmEvCG4tSqoa8vR/YqZoBKCl2FRVvCBH789ct1RQbZUx/YAefO8zWweHpeceox950iKtFU+F1nKrAb8aL6CL2X+WT0hY9I+ZTwmA/u7NYdUrfoRYV9Mhe7gjRVt6O9fKb/kQZRJW8sF6hImQOiQh\ +3TSVv2T/IhKBXso010APoKZDeFaav2BklV+CLC8xD7M41MvO+d/fM1gqpVSH4qDtidzzK38astELkrH/UWpjGlmQ4fXnUxG/6CQnnIR9qcIQwbwE7QKh7RBBL0xS4h6nV9rPy1H1ZpJUQSlpwj+akp9s3I7/a0Q0\ +okzTeyJV+HJZaZCQ8AOn4xecjJOsKRhVq5F56ooKTlNXa8c8CI0ixWiT/zvBDibTH9PIsdffXj657e2rFU018CIbv3z0CiB8Nbk4g/CHP4FwyqOjY1w8fvQcF59PLk5A1W9POj2XKj8dHoImLqMFkPVhs4H2d8RJ\ +MuEoCaKlBFGQal1IkM0kyCad7yXlBfQs7ml6nGWhNMGzAAd2CA702epco/Sa9RW7oLKFQkjTJ01lMFaVshJmsclDGMTu/Ztzw4IVnjJ25sXZYmH14HyHQLcmmbAWZGkx0C4avnWFQOZk7L7Ws2rUeZPMpns0ncTE\ +pelw62OTbjIZ3wVIRVOqDmiLOWWOE5Z+FYDacgWZm0r0zE0UVEq0FzG3aPli3qOTxgLkg9eR3ACmpcGUv3BbZG+1WCW1Ss7zY9kYuhw2X8yPkRoXaTts1RSxp0RZShB1ioiMv0X9c/5cDbtsu6XEAVdppGKIzlsL\ +uey74P0hdtN4EsdD8cQ18YVUxDAQeAn/cbPPtSc2meJW7KiR6+DZ7v2GfOiEOkGVFDPZ9/TT6c+v8dM1nT4RLZLEvN7nm+I2UuLXTqoxS8+m8Vnnkcn5ZzvsuxZ1RGn3DqDsJ0rnOnfRndvZdh2zv+Wp534AfkLV\ +16qEX1jHDLgjsVjErKpcu3kSMWOzOlw7cFFquWFZ5dqQU0VYCgVcmHinhmLDXDPgFq2ZUncO2Vz+DcDQZoxQy207sFqe+YovBKRc0bB4iS38L3IlmUzpCsTMv6K8J8Ydalu0GuMT/lHVOzrieKRMd86cREAjT96B\ +35MIq5H/FbSWKSTA+E3+CsKPRcomfz25kgDqpMnTKgjS+xBhhfU4fAj6UslIaslV0offSZ+BKJl2jtYRJnHpvqyXcrkOrvKI4ibd80wmLRMdA7cuDjY2lVA31jmmGHPNa5ep6rXOtxAOWtE4QmxN11ATQuel1ZVb\ +WTnrdnN6XtCQc2PP9NRdRko9yzANDdEdTZ08gAIANPUwOtCwOzOxMwxv7EjiOsza6QBvIrNVKXsiJcxGBgTpqnS1R8gvnkPGAxjvAKw2gqJHqwuGwqNKdyBu7sRI0W4lVNY2ahQO57kEFxgHWNDleXvQs0FU51Ud\ +rc3NZj9Xu0xJakhH3f4pddmhLXvuf8YYanuDvhlGw5akmVYI15j0O05rp9oBTaR8MuzO2JlnxVYdUea6FfkaP8feKGLvIqryOkE5UWgrfI1a6PPdATIUbhVw/fn5xZXQed1pVRkmj6tF+0J7JdkXgVnTkOAMYpcy\ +7UogMyMFQ9uVBwfzOROZEzGrP+Y4KQymyUtwqmEsesvkULic6aGTZSWxRC7sOG6B7NdIFKUnGo0hr7qMMdLIfdiNIXytFnz27lyZSvlwK6o1wjtQdZNECa1K6KTdlI4Aiqb9TVIJ6myo91rDPePFxGRKT4COKHvk\ +5lxP3BQOezWf+9fYbG2NNBEX5vrUTzodTnEpUnQtpGkE2lX/nN1eFe4oJ7MPuR3uazQh23qIZperZ1zpt/VMi5h6wENNIRusqE6S7uUyCY1BkwD+XF8wXhoRsup/fMicTrVVPnwfV/IXUiqa2ac4+gnk/Jn1CjMV\ +yd0Rr9dqA3H5Y4MPEuZkrdaQtTeH0mLIyWPkHMOy0zTJrajK2EXbrRIn9zUzrnp8PDhrNRr4g24MaSIBzQo2VpVLrdnOc7F9HqDWRCpdAcvJAe217vgi48C3ndRTjjDAsU1/Q7ypikdd4qFP9NiFjg322IgcX8cy\ +Mq+NhVUoFMyT8nvU/Bu/FMBHfR+FfDouYvtvx52wn0QEkxht/RaLXvMsdX0wWrbSOxQt1o/+Dswmgxl8Wg5C9SzJ4ZTOSS7TytOg8qLV7LMQwkgn0yHbuNYzTuiglB9F/+ERdeul7yz1C53ftAsgeSfVQC0Ww5sJ\ +VAdR07rRFJrShI+aQ8qRvOl/pfdKQRhIUvFK5aMCRZvgjYsES8jV09UMWU3N+c2mnDcY2U91C27WvNA8liMqa2Rfzlda8YyIByN4IKhuRpGtiw0uan6Qu4T1LyTPov/IEuZnAbU0t6IC1+T4I5XmW9f/Cj0+v++i\ +M50n7WG3+LHeaxxM9jQ3lSK/KlZVQgsgtXcrIypfah7jdzYOb/sh58VX8bUE3/ejNZCEP+UXJbRa/Y3UxdZqXLQq+n1EAZ0eEu/2jCXixyWT0PrP63gM5fKOAeahXSZ303HE1eQtp7jLQ9JpfP2HNwnHSPwHel9n\ +XRTfpxpjyhlUpB1DpG2Fy1lVU0oYXsfjVlUhRwj/ktB6ITwqtY0vrxlBNHeqcxdZbKSo2ZZQyN+o7l88gBX9t64ryKGQWCnH+1D64h2qCG6Abg+ptv961eqI6d/LOxWhlms67Zr0sqNftZEZrQrBzxZe0UIUPb79\ +8otUUqSeU2l8W8LzNYO5khedFNwV1+bbwiuUEPTljDaPLcf7IfIs7qcwO6uUsMsMWPn7Cw81OYgWzQEkr0CwjpctK6mw+o7I73rKqyDK+GWJFV07eZen3ZETIn2S8n+U92HCC+5L8qlDIHg9D0niCiY9kFcPWs7C\ +rg6eokv5mEVkkf8CQ6yPewfyMgvDqUj/kApVOFwbSlY/feFgRPJl2vTQe9Fj/9yW/IIqNA0flKcNONbTfpFAVb1ThAk6F6XiTU9dUjm0Np03MXyMWkR98hIDvZNCNFNyhGoyOQBQS+F/3ZMGilPXlJyDIgvRBbVf\ +9SAhj0c/Jp+HlEUfpZDdk9peiU6fNMuTFXyyu563HKlLAb+WfWIAUBreEEIoJhWfEcCY5Iv8ZKAt/kT6UFQfv2xfAa+v9kMcqLKzrTcIa28An3/g8nGLWj57vn+CyyeTaaeHT5Rgtr9Zo5c9//VhWl7hlU9r8jyx\ +tkhMuNJcTK8+zQf7/V4RButyWuq7oQBVcKZtGe7OYmyWOJPM/gf0hhEn\ +"""))) +ESP32H2BETA2ROM.STUB_CODE = eval(zlib.decompress(base64.b64decode(b""" +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\ +"""))) +ESP32C2ROM.STUB_CODE = eval(zlib.decompress(base64.b64decode(b""" +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\ """))) From 96232e9def49526623278f024cfa19cb6037d2b8 Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Fri, 6 Sep 2024 15:55:03 +0200 Subject: [PATCH 05/58] #78: reset wifi nvs storage and do not store wifi data in nvs any more --- lib/gwwifi/GwWifi.cpp | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/lib/gwwifi/GwWifi.cpp b/lib/gwwifi/GwWifi.cpp index 549f99d..69715d2 100644 --- a/lib/gwwifi/GwWifi.cpp +++ b/lib/gwwifi/GwWifi.cpp @@ -1,3 +1,4 @@ +#include <esp_wifi.h> #include "GWWifi.h" @@ -35,7 +36,29 @@ void GwWifi::setup(){ LOG_DEBUG(GwLog::ERROR,"unable to set access point mask %s, falling back to %s", apMask.c_str(),AP_subnet.toString().c_str()); } + //try to remove any existing config from nvs + //this will avoid issues when updating from framework 6.3.2 to 6.8.1 - see #78 + //we do not need the nvs config any way - so we set persistent to false + //unfortunately this will be to late (config from nvs has already been loaded) + //if we update from an older version that has config in the nvs + //so we need to make a dummy init, erase the flash and deinit + wifi_config_t conf_current; + wifi_init_config_t conf=WIFI_INIT_CONFIG_DEFAULT(); + esp_err_t err=esp_wifi_init(&conf); + esp_wifi_get_config((wifi_interface_t)WIFI_IF_AP, &conf_current); + LOG_DEBUG(GwLog::DEBUG,"Wifi AP old config before reset ssid=%s, pass=%s, channel=%d",conf_current.ap.ssid,conf_current.ap.password,conf_current.ap.channel); + if (err){ + LOG_DEBUG(GwLog::ERROR,"unable to pre-init wifi: %d",(int)err); + } + err=esp_wifi_restore(); + if (err){ + LOG_DEBUG(GwLog::ERROR,"unable to reset wifi: %d",(int)err); + } + err=esp_wifi_deinit(); + WiFi.persistent(false); WiFi.mode(WIFI_MODE_APSTA); //enable both AP and client + esp_wifi_get_config((wifi_interface_t)WIFI_IF_AP, &conf_current); + LOG_DEBUG(GwLog::DEBUG,"Wifi AP old config after reset ssid=%s, pass=%s, channel=%d",conf_current.ap.ssid,conf_current.ap.password,conf_current.ap.channel); const char *ssid=config->getConfigItem(config->systemName)->asCString(); if (fixedApPass){ WiFi.softAP(ssid,AP_password); @@ -45,7 +68,7 @@ void GwWifi::setup(){ } delay(100); WiFi.softAPConfig(AP_local_ip, AP_gateway, AP_subnet); - LOG_DEBUG(GwLog::LOG,"WifiAP created: ssid=%s,adress=%s", + LOG_DEBUG(GwLog::ERROR,"WifiAP created: ssid=%s,adress=%s", ssid, WiFi.softAPIP().toString().c_str() ); From 20b108ac45932e95d2a1a3d64946b5d87897d479 Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Fri, 6 Sep 2024 15:55:32 +0200 Subject: [PATCH 06/58] update flashtool again --- tools/flashtool.pyz | Bin 467869 -> 683141 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/tools/flashtool.pyz b/tools/flashtool.pyz index a3f96072c08ddc78470948229227a964052639e1..5dca3bbb793d44a7b8508d25e66754f0627725b8 100644 GIT binary patch delta 104002 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 Uy16dIbe8D29P6&v;O%hyFWku}@&Et; delta 184 zcmZp@sX6z!%!YI3%(GL&n=hKTUo>X~VkRJF-hR=XWrh9p#o1iK?U(km12M<;OZz!j zu4H12*<PE;CBz8QG<{MQS3XFQ>~y(oF2(8Z_p`H3f0oUq#Z)RVy)c_gKEzC~pfWkY zn~_O`S%d)u7-Q5TCFH^!t$=J0<^~BuK?9?<B0EETd~RZ9UVJ=IXMi^=8%T}=2!Ap% KF!TYX85jVo8#FKg From c1e8848f2524e74069e20db475db2a12471836eb Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Fri, 6 Sep 2024 17:20:38 +0200 Subject: [PATCH 07/58] update webinstaller to esptool 0.4.5 --- webinstall/installUtil.js | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/webinstall/installUtil.js b/webinstall/installUtil.js index 14910d8..330310a 100644 --- a/webinstall/installUtil.js +++ b/webinstall/installUtil.js @@ -1,4 +1,4 @@ -import {ESPLoader,Transport} from "https://cdn.jsdelivr.net/npm/esptool-js@0.2.1/bundle.js"; +import {ESPLoader,Transport} from "https://cdn.jsdelivr.net/npm/esptool-js@0.4.5/bundle.js"; /** * write all messages to the console */ @@ -102,10 +102,14 @@ class ESPInstaller{ await this.consoleReader.cancel(); this.consoleReader=undefined; } - await this.consoleDevice.close(); }catch(e){ console.log(`error cancel serial read ${e}`); } + try{ + await this.consoleDevice.close(); + }catch(e){ + console.log('error closing console device', this.consoleDevice,e); + } this.consoleDevice=undefined; } if (this.transport){ @@ -126,13 +130,17 @@ class ESPInstaller{ } try { this.transport = new Transport(device); - this.esploader = new ESPLoader(this.transport, 115200, this.espLoaderTerminal); - let foundChip = await this.esploader.main_fn(); + this.esploader = new ESPLoader({ + transport:this.transport, + baudrate: 115200, + terminal: this.espLoaderTerminal}); + //this.esploader.debugLogging=true; + let foundChip = await this.esploader.main(); if (!foundChip) { throw new Error("unable to read chip id"); } this.espLoaderTerminal.writeLine(`chip: ${foundChip}`); - await this.esploader.flash_id(); + //await this.esploader.flashId(); this.chipFamily = this.esploader.chip.CHIP_NAME; this.imageChipId = this.esploader.chip.IMAGE_CHIP_ID; this.espLoaderTerminal.writeLine(`chipFamily: ${this.chipFamily}`); @@ -198,12 +206,17 @@ class ESPInstaller{ async writeFlash(fileList){ this.checkConnected(); this.espLoaderTerminal.writeLine(`Flashing....`); - await this.esploader.write_flash( - fileList, - "keep", - "keep", - "keep", - false + await this.esploader.writeFlash({ + fileArray: fileList, + flashSize: "keep", + flashMode: "keep", + flashFreq: "keep", + eraseAll: false, + compress: true, + reportProgress: (fileIndex, written, total)=>{ + this.espLoaderTerminal.writeLine(`file ${fileIndex}: ${written}/${total}`); + } + } ) await this.resetTransport(); this.espLoaderTerminal.writeLine(`Done.`); From c49cda2e57734273df83760c7862e0c94b48809a Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Fri, 6 Sep 2024 17:31:20 +0200 Subject: [PATCH 08/58] remove additional progress from webinstaller --- webinstall/installUtil.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webinstall/installUtil.js b/webinstall/installUtil.js index 330310a..c737d42 100644 --- a/webinstall/installUtil.js +++ b/webinstall/installUtil.js @@ -213,9 +213,9 @@ class ESPInstaller{ flashFreq: "keep", eraseAll: false, compress: true, - reportProgress: (fileIndex, written, total)=>{ + /*reportProgress: (fileIndex, written, total)=>{ this.espLoaderTerminal.writeLine(`file ${fileIndex}: ${written}/${total}`); - } + }*/ } ) await this.resetTransport(); From bd793b8306034d50fb55e4838f06e6f29ab2f46c Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Fri, 6 Sep 2024 19:17:00 +0200 Subject: [PATCH 09/58] wipe nvs partition on factory reset --- lib/config/GWConfig.cpp | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/lib/config/GWConfig.cpp b/lib/config/GWConfig.cpp index e53870a..b372d3d 100644 --- a/lib/config/GWConfig.cpp +++ b/lib/config/GWConfig.cpp @@ -4,6 +4,7 @@ #include <ArduinoJson.h> #include <string.h> #include <MD5Builder.h> +#include <esp_partition.h> using CfgInit=std::function<void(GwConfigHandler *)>; static std::vector<CfgInit> cfgInits; #define CFG_INIT(name,value,mode) \ @@ -96,15 +97,36 @@ bool GwConfigHandler::updateValue(String name, String value){ if (i->asString() == value){ return false; } - LOG_DEBUG(GwLog::LOG,"update config %s=>%s",name.c_str(),i->isSecret()?"***":value.c_str()); prefs->begin(PREF_NAME,false); prefs->putString(i->getName().c_str(),value); + LOG_DEBUG(GwLog::LOG,"update config %s=>%s, freeEntries=%d",name.c_str(),i->isSecret()?"***":value.c_str(),(int)(prefs->freeEntries())); prefs->end(); } return true; } bool GwConfigHandler::reset(){ - LOG_DEBUG(GwLog::LOG,"reset config"); + LOG_DEBUG(GwLog::ERROR,"reset config"); + //try to find the nvs partition + //currently we only support the default + bool wiped=false; + const esp_partition_t *nvspart=esp_partition_find_first(ESP_PARTITION_TYPE_DATA,ESP_PARTITION_SUBTYPE_DATA_NVS,"nvs"); + if (nvspart != NULL){ + LOG_DEBUG(GwLog::ERROR,"wiping nvs partition"); + esp_err_t err=esp_partition_erase_range(nvspart,0,nvspart->size); + if (err != ESP_OK){ + LOG_DEBUG(GwLog::ERROR,"wiping nvs partition failed: %d",(int)err); + } + else{ + wiped=true; + } + } + else{ + LOG_DEBUG(GwLog::ERROR,"nvs partition not found"); + } + if (wiped){ + return true; + } + LOG_DEBUG(GwLog::ERROR,"unable to wipe nvs partition, trying to reset values"); prefs->begin(PREF_NAME,false); for (int i=0;i<getNumConfig();i++){ prefs->putString(configs[i]->getName().c_str(),configs[i]->getDefault()); From e29e8eb5914c1ad1e4912cbf071b395c1814549a Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Sat, 7 Sep 2024 11:35:50 +0200 Subject: [PATCH 10/58] add example config for led --- lib/ledtask/GwLedTask.cpp | 2 +- lib/ledtask/platformio.ini | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 lib/ledtask/platformio.ini diff --git a/lib/ledtask/GwLedTask.cpp b/lib/ledtask/GwLedTask.cpp index 5df1e9d..bda304e 100644 --- a/lib/ledtask/GwLedTask.cpp +++ b/lib/ledtask/GwLedTask.cpp @@ -43,7 +43,7 @@ void handleLeds(GwApi *api){ leds[0]=colorFromMode(currentMode); FastLED.setBrightness(brightness); FastLED.show(); - LOG_DEBUG(GwLog::LOG,"led task started with mode %d",(int)currentMode); + LOG_DEBUG(GwLog::LOG,"led task started with mode %d, brightness=%d",(int)currentMode,(int)brightness); int apiResult=0; while (true) { diff --git a/lib/ledtask/platformio.ini b/lib/ledtask/platformio.ini new file mode 100644 index 0000000..c883d3b --- /dev/null +++ b/lib/ledtask/platformio.ini @@ -0,0 +1,15 @@ +[platformio] +#if you want a pio run to only build +#your special environments you can set this here +#by uncommenting the next line +#default_envs = testboard +[env:nodemculed] +board = nodemcu-32s +lib_deps = ${env.lib_deps} +build_flags = + -D BOARD_HOMBERGER + -D GWLED_CODE=1 + -D GWLED_PIN=33 + ${env.build_flags} +upload_port = /dev/esp32 +upload_protocol = esptool From d63b4d16619d7f938c58c56606ee4fa8f65b7a53 Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Wed, 18 Sep 2024 17:47:43 +0200 Subject: [PATCH 11/58] correctly terminate the current logbuffer when partially written --- lib/channel/GwChannelList.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/channel/GwChannelList.cpp b/lib/channel/GwChannelList.cpp index a118f13..c736ae2 100644 --- a/lib/channel/GwChannelList.cpp +++ b/lib/channel/GwChannelList.cpp @@ -72,7 +72,7 @@ public: if (handled > 0){ memmove(logBuffer,logBuffer+handled,wp-handled); wp-=handled; - logBuffer[handled]=0; + logBuffer[wp]=0; } return; } From 55145726c3a936f65494701e421fa57150a04778 Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Thu, 19 Sep 2024 19:51:28 +0200 Subject: [PATCH 12/58] only link FastLED stuff when GWLED_FASTLED is defined --- lib/ledtask/GwLedTask.cpp | 74 ++++++++++++++++++++++----------------- lib/ledtask/GwLedTask.h | 7 ++-- 2 files changed, 45 insertions(+), 36 deletions(-) diff --git a/lib/ledtask/GwLedTask.cpp b/lib/ledtask/GwLedTask.cpp index bda304e..0a03005 100644 --- a/lib/ledtask/GwLedTask.cpp +++ b/lib/ledtask/GwLedTask.cpp @@ -1,8 +1,19 @@ #include "GwLedTask.h" #include "GwHardware.h" #include "GwApi.h" + +void handleLeds(GwApi *api); +void initLeds(GwApi *param) +{ +#ifdef GWLED_FASTLED + param->addUserTask(handleLeds, "handleLeds"); +#endif +} + +#ifdef GWLED_FASTLED #include "FastLED.h" -typedef enum { +typedef enum +{ LED_OFF, LED_GREEN, LED_BLUE, @@ -10,41 +21,38 @@ typedef enum { LED_WHITE } GwLedMode; -static CRGB::HTMLColorCode colorFromMode(GwLedMode cmode){ - switch(cmode){ - case LED_BLUE: - return CRGB::Blue; - case LED_GREEN: - return CRGB::Green; - case LED_RED: - return CRGB::Red; - case LED_WHITE: - return CRGB::White; - default: - return CRGB::Black; +static CRGB::HTMLColorCode colorFromMode(GwLedMode cmode) +{ + switch (cmode) + { + case LED_BLUE: + return CRGB::Blue; + case LED_GREEN: + return CRGB::Green; + case LED_RED: + return CRGB::Red; + case LED_WHITE: + return CRGB::White; + default: + return CRGB::Black; } } -void handleLeds(GwApi *api){ - GwLog *logger=api->getLogger(); - #ifndef GWLED_FASTLED - LOG_DEBUG(GwLog::LOG,"currently only fastled handling"); - delay(50); - vTaskDelete(NULL); - return; - #else +void handleLeds(GwApi *api) +{ + GwLog *logger = api->getLogger(); CRGB leds[1]; - #ifdef GWLED_SCHEMA - FastLED.addLeds<GWLED_TYPE,GWLED_PIN,(EOrder)GWLED_SCHEMA>(leds,1); - #else - FastLED.addLeds<GWLED_TYPE,GWLED_PIN>(leds,1); - #endif - uint8_t brightness=api->getConfig()->getInt(GwConfigDefinitions::ledBrightness,128); - GwLedMode currentMode=LED_GREEN; - leds[0]=colorFromMode(currentMode); +#ifdef GWLED_SCHEMA + FastLED.addLeds<GWLED_TYPE, GWLED_PIN, (EOrder)GWLED_SCHEMA>(leds, 1); +#else + FastLED.addLeds<GWLED_TYPE, GWLED_PIN>(leds, 1); +#endif + uint8_t brightness = api->getConfig()->getInt(GwConfigDefinitions::ledBrightness, 128); + GwLedMode currentMode = LED_GREEN; + leds[0] = colorFromMode(currentMode); FastLED.setBrightness(brightness); FastLED.show(); - LOG_DEBUG(GwLog::LOG,"led task started with mode %d, brightness=%d",(int)currentMode,(int)brightness); - int apiResult=0; + LOG_DEBUG(GwLog::LOG, "led task started with mode %d, brightness=%d", (int)currentMode, (int)brightness); + int apiResult = 0; while (true) { delay(50); @@ -77,5 +85,5 @@ void handleLeds(GwApi *api){ } } vTaskDelete(NULL); - #endif -} \ No newline at end of file +} +#endif \ No newline at end of file diff --git a/lib/ledtask/GwLedTask.h b/lib/ledtask/GwLedTask.h index 2ae1532..03cb980 100644 --- a/lib/ledtask/GwLedTask.h +++ b/lib/ledtask/GwLedTask.h @@ -1,8 +1,9 @@ #ifndef _GWLEDS_H #define _GWLEDS_H #include "GwApi.h" -//task function -void handleLeds(GwApi *param); +//task init function -DECLARE_USERTASK(handleLeds); +void initLeds(GwApi *param); + +DECLARE_INITFUNCTION(initLeds); #endif \ No newline at end of file From eacefd4b09e8562057f2fe84964ae80a91449427 Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Thu, 19 Sep 2024 20:00:04 +0200 Subject: [PATCH 13/58] split dependencies to allow for builds without FastLED --- platformio.ini | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/platformio.ini b/platformio.ini index b249ef8..0616d7f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -16,6 +16,19 @@ default_envs= extra_configs= lib/*task*/platformio.ini +[basedeps] +lib_deps = + ttlappalainen/NMEA2000-library @ 4.18.9 + ttlappalainen/NMEA0183 @ 1.9.1 + ArduinoJson @ 6.18.5 + AsyncTCP-esphome @ 2.0.1 + ottowinter/ESPAsyncWebServer-esphome@2.0.1 + FS + Preferences + ESPmDNS + WiFi + Update + [env] platform = espressif32 @ 6.3.2 framework = arduino @@ -23,18 +36,8 @@ framework = arduino ; framework-arduinoespressif32 @ 3.20011.230801 ; framework-espidf @ 3.50101.0 lib_deps = - ttlappalainen/NMEA2000-library @ 4.18.9 - ttlappalainen/NMEA0183 @ 1.9.1 - ArduinoJson @ 6.18.5 - AsyncTCP-esphome @ 2.0.1 - ottowinter/ESPAsyncWebServer-esphome@2.0.1 - fastled/FastLED @ 3.6.0 - FS - Preferences - ESPmDNS - WiFi - Update - + ${basedeps.lib_deps} + fastled/FastLED @ 3.6.0 board_build.embed_files = From 4ede06e33d85f15d8f8e4ed73b7e2f6ef016db31 Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Sun, 29 Sep 2024 16:27:20 +0200 Subject: [PATCH 14/58] untested: move to nmea2000 library 4.22.0, nmea0183 1.10.1 --- lib/nmea2kto0183/N2kDataToNMEA0183.cpp | 28 +++++++++++++++++--------- platformio.ini | 4 ++-- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/lib/nmea2kto0183/N2kDataToNMEA0183.cpp b/lib/nmea2kto0183/N2kDataToNMEA0183.cpp index 26c6811..35ab4b7 100644 --- a/lib/nmea2kto0183/N2kDataToNMEA0183.cpp +++ b/lib/nmea2kto0183/N2kDataToNMEA0183.cpp @@ -657,12 +657,14 @@ private: double _Heading=N2kDoubleNA; double _ROT=N2kDoubleNA; tN2kAISNavStatus _NavStatus; + tN2kAISTransceiverInformation _AISTransceiverInformation; + uint8_t _SID; uint8_t _MessageType = 1; tNMEA0183AISMsg NMEA0183AISMsg; if (ParseN2kPGN129038(N2kMsg, SID, _Repeat, _UserID, _Latitude, _Longitude, _Accuracy, _RAIM, _Seconds, - _COG, _SOG, _Heading, _ROT, _NavStatus)) + _COG, _SOG, _Heading, _ROT, _NavStatus,_AISTransceiverInformation,_SID)) { // Debug @@ -746,12 +748,13 @@ private: tN2kGNSStype _GNSStype; tN2kAISTransceiverInformation _AISinfo; tN2kAISDTE _DTE; + uint8_t _SID; tNMEA0183AISMsg NMEA0183AISMsg; - if (ParseN2kPGN129794(N2kMsg, _MessageID, _Repeat, _UserID, _IMONumber, _Callsign, _Name, _VesselType, - _Length, _Beam, _PosRefStbd, _PosRefBow, _ETAdate, _ETAtime, _Draught, _Destination, - _AISversion, _GNSStype, _DTE, _AISinfo)) + if (ParseN2kPGN129794(N2kMsg, _MessageID, _Repeat, _UserID, _IMONumber, _Callsign, 8, _Name,21, _VesselType, + _Length, _Beam, _PosRefStbd, _PosRefBow, _ETAdate, _ETAtime, _Draught, _Destination,21, + _AISversion, _GNSStype, _DTE, _AISinfo,_SID)) { #ifdef SERIAL_PRINT_AIS_FIELDS @@ -855,9 +858,10 @@ private: bool _Display, _DSC, _Band, _Msg22, _State; tN2kAISMode _Mode; tN2kAISTransceiverInformation _AISTranceiverInformation; + uint8_t _SID; if (ParseN2kPGN129039(N2kMsg, _MessageID, _Repeat, _UserID, _Latitude, _Longitude, _Accuracy, _RAIM, - _Seconds, _COG, _SOG, _AISTranceiverInformation, _Heading, _Unit, _Display, _DSC, _Band, _Msg22, _Mode, _State)) + _Seconds, _COG, _SOG, _AISTranceiverInformation, _Heading, _Unit, _Display, _DSC, _Band, _Msg22, _Mode, _State,_SID)) { tNMEA0183AISMsg NMEA0183AISMsg; @@ -896,8 +900,10 @@ private: tN2kAISRepeat _Repeat; uint32_t _UserID; // MMSI char _Name[21]; + tN2kAISTransceiverInformation _AISInfo; + uint8_t _SID; - if (ParseN2kPGN129809(N2kMsg, _MessageID, _Repeat, _UserID, _Name)) + if (ParseN2kPGN129809(N2kMsg, _MessageID, _Repeat, _UserID, _Name,21,_AISInfo,_SID)) { tNMEA0183AISMsg NMEA0183AISMsg; @@ -923,9 +929,11 @@ private: double _Beam=N2kDoubleNA; double _PosRefStbd=N2kDoubleNA; double _PosRefBow=N2kDoubleNA; + tN2kAISTransceiverInformation _AISInfo; + uint8_t _SID; - if (ParseN2kPGN129810(N2kMsg, _MessageID, _Repeat, _UserID, _VesselType, _Vendor, _Callsign, - _Length, _Beam, _PosRefStbd, _PosRefBow, _MothershipID)) + if (ParseN2kPGN129810(N2kMsg, _MessageID, _Repeat, _UserID, _VesselType, _Vendor,4, _Callsign,8, + _Length, _Beam, _PosRefStbd, _PosRefBow, _MothershipID,_AISInfo,_SID)) { // @@ -1121,8 +1129,8 @@ private: int16_t ETADate=0; double BearingOriginToDestinationWaypoint=N2kDoubleNA; double BearingPositionToDestinationWaypoint=N2kDoubleNA; - uint8_t OriginWaypointNumber; - uint8_t DestinationWaypointNumber; + uint32_t OriginWaypointNumber; + uint32_t DestinationWaypointNumber; double DestinationLatitude=N2kDoubleNA; double DestinationLongitude=N2kDoubleNA; double WaypointClosingVelocity=N2kDoubleNA; diff --git a/platformio.ini b/platformio.ini index 0616d7f..2ed6e04 100644 --- a/platformio.ini +++ b/platformio.ini @@ -18,8 +18,8 @@ extra_configs= [basedeps] lib_deps = - ttlappalainen/NMEA2000-library @ 4.18.9 - ttlappalainen/NMEA0183 @ 1.9.1 + ttlappalainen/NMEA2000-library @ 4.22.0 + ttlappalainen/NMEA0183 @ 1.10.1 ArduinoJson @ 6.18.5 AsyncTCP-esphome @ 2.0.1 ottowinter/ESPAsyncWebServer-esphome@2.0.1 From c266bddea3001c526744bddccbe88915a032f7e2 Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Sun, 29 Sep 2024 19:56:39 +0200 Subject: [PATCH 15/58] make data store times configurable --- lib/boatData/GwBoatData.cpp | 63 ++++++++++++++-- lib/boatData/GwBoatData.h | 115 +++++++++++++++++------------- lib/xdrmappings/GwXDRMappings.cpp | 8 ++- lib/xdrmappings/GwXDRMappings.h | 10 ++- src/main.cpp | 2 +- web/config.json | 40 +++++++++++ 6 files changed, 176 insertions(+), 62 deletions(-) diff --git a/lib/boatData/GwBoatData.cpp b/lib/boatData/GwBoatData.cpp index df61ddf..f7be617 100644 --- a/lib/boatData/GwBoatData.cpp +++ b/lib/boatData/GwBoatData.cpp @@ -1,6 +1,7 @@ #include "GwBoatData.h" #include <GwJsonDocument.h> #include <ArduinoJson/Json/TextFormatter.hpp> +#include "GWConfig.h" #define GWTYPE_DOUBLE 1 #define GWTYPE_UINT32 2 #define GWTYPE_UINT16 3 @@ -35,6 +36,23 @@ GwBoatItemBase::GwBoatItemBase(String name, String format, unsigned long invalid this->format = format; this->type = 0; this->lastUpdateSource = -1; + this->toType=TOType::user; +} +GwBoatItemBase::GwBoatItemBase(String name, String format, GwBoatItemBase::TOType toType) +{ + lastSet = 0; + this->invalidTime = INVALID_TIME; + this->toType=toType; + this->name = name; + this->format = format; + this->type = 0; + this->lastUpdateSource = -1; + this->toType=TOType::user; +} +void GwBoatItemBase::setInvalidTime(unsigned long it, bool force){ + if (toType != TOType::user || force ){ + invalidTime=it; + } } size_t GwBoatItemBase::getJsonSize() { return JSON_OBJECT_SIZE(10); } #define STRING_SIZE 40 @@ -113,6 +131,16 @@ GwBoatItem<T>::GwBoatItem(String name, String formatInfo, unsigned long invalidT (*map)[name] = this; } } +template <class T> +GwBoatItem<T>::GwBoatItem(String name, String formatInfo, GwBoatItemBase::TOType toType, GwBoatItemMap *map) : GwBoatItemBase(name, formatInfo, toType) +{ + T dummy; + this->type = GwBoatItemTypes::getType(dummy); + if (map) + { + (*map)[name] = this; + } +} template <class T> bool GwBoatItem<T>::update(T nv, int source) @@ -246,14 +274,13 @@ void GwSatInfoList::houseKeeping(unsigned long ts) sats.end(), [ts, this](const GwSatInfo &info) { - return (info.timestamp + lifeTime) < ts; + return info.validTill < ts; }), sats.end()); } -void GwSatInfoList::update(GwSatInfo entry) +void GwSatInfoList::update(GwSatInfo entry, unsigned long validTill) { - unsigned long now = millis(); - entry.timestamp = now; + entry.validTill = validTill; for (auto it = sats.begin(); it != sats.end(); it++) { if (it->PRN == entry.PRN) @@ -267,7 +294,7 @@ void GwSatInfoList::update(GwSatInfo entry) sats.push_back(entry); } -GwBoatDataSatList::GwBoatDataSatList(String name, String formatInfo, unsigned long invalidTime, GwBoatItemMap *map) : GwBoatItem<GwSatInfoList>(name, formatInfo, invalidTime, map) {} +GwBoatDataSatList::GwBoatDataSatList(String name, String formatInfo, GwBoatItemBase::TOType toType, GwBoatItemMap *map) : GwBoatItem<GwSatInfoList>(name, formatInfo, toType, map) {} bool GwBoatDataSatList::update(GwSatInfo info, int source) { @@ -284,7 +311,7 @@ bool GwBoatDataSatList::update(GwSatInfo info, int source) } lastUpdateSource = source; uls(now); - data.update(info); + data.update(info,now+invalidTime); return true; } void GwBoatDataSatList::toJsonDoc(GwJsonDocument *doc, unsigned long minTime) @@ -293,9 +320,31 @@ void GwBoatDataSatList::toJsonDoc(GwJsonDocument *doc, unsigned long minTime) GwBoatItem<GwSatInfoList>::toJsonDoc(doc, minTime); } -GwBoatData::GwBoatData(GwLog *logger) +GwBoatData::GwBoatData(GwLog *logger, GwConfigHandler *cfg) { this->logger = logger; + for (auto &&it : values){ + unsigned long timeout=GwBoatItemBase::INVALID_TIME; + switch(it.second->getToType()){ + case GwBoatItemBase::TOType::ais: + timeout=cfg->getInt(GwConfigDefinitions::timoAis); + break; + case GwBoatItemBase::TOType::def: + timeout=cfg->getInt(GwConfigDefinitions::timoDefault); + break; + case GwBoatItemBase::TOType::lng: + timeout=cfg->getInt(GwConfigDefinitions::timoLong); + break; + case GwBoatItemBase::TOType::sensor: + timeout=cfg->getInt(GwConfigDefinitions::timoSensor); + break; + case GwBoatItemBase::TOType::keep: + timeout=0; + break; + } + it.second->setInvalidTime(timeout); + } + } GwBoatData::~GwBoatData() { diff --git a/lib/boatData/GwBoatData.h b/lib/boatData/GwBoatData.h index f5af8e8..1c4394c 100644 --- a/lib/boatData/GwBoatData.h +++ b/lib/boatData/GwBoatData.h @@ -2,6 +2,7 @@ #define _GWBOATDATA_H #include "GwLog.h" +#include "GWConfig.h" #include <Arduino.h> #include <map> #include <vector> @@ -15,6 +16,14 @@ class GwJsonDocument; class GwBoatItemBase{ public: + using TOType=enum{ + def=1, + ais=2, + sensor=3, + lng=4, + user=5, + keep=6 + }; class StringWriter{ uint8_t *buffer =NULL; uint8_t *wp=NULL; @@ -31,7 +40,7 @@ class GwBoatItemBase{ bool baseFilled(); void reset(); }; - static const unsigned long INVALID_TIME=60000; + static const long INVALID_TIME=60000; //the formatter names that must be known in js GWSC(formatCourse); GWSC(formatKnots); @@ -51,10 +60,11 @@ class GwBoatItemBase{ protected: int type; unsigned long lastSet=0; - unsigned long invalidTime=INVALID_TIME; + long invalidTime=INVALID_TIME; String name; String format; StringWriter writer; + TOType toType=TOType::def; void uls(unsigned long ts=0){ if (ts) lastSet=ts; else lastSet=millis(); @@ -65,7 +75,8 @@ class GwBoatItemBase{ int getCurrentType(){return type;} unsigned long getLastSet() const {return lastSet;} bool isValid(unsigned long now=0) const ; - GwBoatItemBase(String name,String format,unsigned long invalidTime=INVALID_TIME); + GwBoatItemBase(String name,String format,TOType toType); + GwBoatItemBase(String name,String format,unsigned long invalidTime); virtual ~GwBoatItemBase(){} void invalidate(){ lastSet=0; @@ -82,6 +93,8 @@ class GwBoatItemBase{ virtual double getDoubleValue()=0; String getName(){return name;} const String & getFormat() const{return format;} + virtual void setInvalidTime(unsigned long it, bool force=true); + TOType getToType(){return toType;} }; class GwBoatData; template<class T> class GwBoatItem : public GwBoatItemBase{ @@ -90,6 +103,7 @@ template<class T> class GwBoatItem : public GwBoatItemBase{ bool lastStringValid=false; public: GwBoatItem(String name,String formatInfo,unsigned long invalidTime=INVALID_TIME,GwBoatItemMap *map=NULL); + GwBoatItem(String name,String formatInfo,TOType toType,GwBoatItemMap *map=NULL); virtual ~GwBoatItem(){} bool update(T nv, int source=-1); bool updateMax(T nv,int sourceId=-1); @@ -118,14 +132,14 @@ class GwSatInfo{ uint32_t Elevation; uint32_t Azimut; uint32_t SNR; - unsigned long timestamp; + unsigned long validTill; }; class GwSatInfoList{ public: - static const unsigned long lifeTime=32000; + static const GwBoatItemBase::TOType toType=GwBoatItemBase::TOType::lng; std::vector<GwSatInfo> sats; void houseKeeping(unsigned long ts=0); - void update(GwSatInfo entry); + void update(GwSatInfo entry, unsigned long validTill); int getNumSats() const{ return sats.size(); } @@ -139,7 +153,7 @@ class GwSatInfoList{ class GwBoatDataSatList : public GwBoatItem<GwSatInfoList> { public: - GwBoatDataSatList(String name, String formatInfo, unsigned long invalidTime = INVALID_TIME, GwBoatItemMap *map = NULL); + GwBoatDataSatList(String name, String formatInfo, GwBoatItemBase::TOType toType, GwBoatItemMap *map = NULL); bool update(GwSatInfo info, int source); virtual void toJsonDoc(GwJsonDocument *doc, unsigned long minTime); GwSatInfo *getAt(int idx){ @@ -164,56 +178,59 @@ public: virtual unsigned long getInvalidTime(){ return GwBoatItemBase::INVALID_TIME;} virtual ~GwBoatItemNameProvider() {} }; -#define GWBOATDATA(type,name,time,fmt) \ +#define GWBOATDATAT(type,name,toType,fmt) \ static constexpr const char* _##name=#name; \ - GwBoatItem<type> *name=new GwBoatItem<type>(#name,GwBoatItemBase::fmt,time,&values) ; -#define GWSPECBOATDATA(clazz,name,time,fmt) \ - clazz *name=new clazz(#name,GwBoatItemBase::fmt,time,&values) ; + GwBoatItem<type> *name=new GwBoatItem<type>(#name,GwBoatItemBase::fmt,toType,&values) ; +#define GWBOATDATA(type,name,fmt) GWBOATDATAT(type,name,GwBoatItemBase::TOType::def,fmt) +#define GWSPECBOATDATA(clazz,name,toType,fmt) \ + clazz *name=new clazz(#name,GwBoatItemBase::fmt,toType,&values) ; class GwBoatData{ + static const unsigned long DEF_TIME=4000; private: GwLog *logger; GwBoatItemBase::GwBoatItemMap values; public: - GWBOATDATA(double,COG,4000,formatCourse) // course over ground - GWBOATDATA(double,SOG,4000,formatKnots) // speed over ground - GWBOATDATA(double,HDT,4000,formatCourse) // true heading - GWBOATDATA(double,HDM,4000,formatCourse) // magnetic heading - GWBOATDATA(double,STW,4000,formatKnots) // water speed - GWBOATDATA(double,VAR,4000,formatWind) // variation - GWBOATDATA(double,DEV,4000,formatWind) // deviation - GWBOATDATA(double,AWA,4000,formatWind) // apparent wind ANGLE - GWBOATDATA(double,AWS,4000,formatKnots) // apparent wind speed - GWBOATDATA(double,MaxAws,0,formatKnots) - GWBOATDATA(double,TWD,4000,formatCourse) // true wind DIRECTION - GWBOATDATA(double,TWA,4000,formatWind) // true wind ANGLE - GWBOATDATA(double,TWS,4000,formatKnots) // true wind speed - GWBOATDATA(double,MaxTws,0,formatKnots) - GWBOATDATA(double,ROT,4000,formatRot) // rate of turn - GWBOATDATA(double,RPOS,4000,formatWind) // rudder position - GWBOATDATA(double,PRPOS,4000,formatWind) // secondary rudder position - GWBOATDATA(double,LAT,4000,formatLatitude) - GWBOATDATA(double,LON,4000,formatLongitude) - GWBOATDATA(double,ALT,4000,formatFixed0) //altitude - GWBOATDATA(double,HDOP,4000,formatDop) - GWBOATDATA(double,PDOP,4000,formatDop) - GWBOATDATA(double,VDOP,4000,formatDop) - GWBOATDATA(double,DBS,4000,formatDepth) //waterDepth (below surface) - GWBOATDATA(double,DBT,4000,formatDepth) //DepthTransducer - GWBOATDATA(double,GPST,4000,formatTime) // GPS time (seconds of day) - GWBOATDATA(uint32_t,GPSD,4000,formatDate) // GPS date (days since 1979-01-01) - GWBOATDATA(int16_t,TZ,8000,formatFixed0) - GWBOATDATA(double,WTemp,4000,kelvinToC) - GWBOATDATA(uint32_t,Log,16000,mtr2nm) - GWBOATDATA(uint32_t,TripLog,16000,mtr2nm) - GWBOATDATA(double,DTW,4000,mtr2nm) // distance to waypoint - GWBOATDATA(double,BTW,4000,formatCourse) // bearing to waypoint - GWBOATDATA(double,XTE,4000,formatXte) // cross track error - GWBOATDATA(double,WPLat,4000,formatLatitude) // waypoint latitude - GWBOATDATA(double,WPLon,4000,formatLongitude) // waypoint longitude - GWSPECBOATDATA(GwBoatDataSatList,SatInfo,GwSatInfoList::lifeTime,formatFixed0); + GWBOATDATA(double,COG,formatCourse) // course over ground + GWBOATDATA(double,SOG,formatKnots) // speed over ground + GWBOATDATA(double,HDT,formatCourse) // true heading + GWBOATDATA(double,HDM,formatCourse) // magnetic heading + GWBOATDATA(double,STW,formatKnots) // water speed + GWBOATDATA(double,VAR,formatWind) // variation + GWBOATDATA(double,DEV,formatWind) // deviation + GWBOATDATA(double,AWA,formatWind) // apparent wind ANGLE + GWBOATDATA(double,AWS,formatKnots) // apparent wind speed + GWBOATDATAT(double,MaxAws,GwBoatItemBase::TOType::keep,formatKnots) + GWBOATDATA(double,TWD,formatCourse) // true wind DIRECTION + GWBOATDATA(double,TWA,formatWind) // true wind ANGLE + GWBOATDATA(double,TWS,formatKnots) // true wind speed + + GWBOATDATAT(double,MaxTws,GwBoatItemBase::TOType::keep,formatKnots) + GWBOATDATA(double,ROT,formatRot) // rate of turn + GWBOATDATA(double,RPOS,formatWind) // rudder position + GWBOATDATA(double,PRPOS,formatWind) // secondary rudder position + GWBOATDATA(double,LAT,formatLatitude) + GWBOATDATA(double,LON,formatLongitude) + GWBOATDATA(double,ALT,formatFixed0) //altitude + GWBOATDATA(double,HDOP,formatDop) + GWBOATDATA(double,PDOP,formatDop) + GWBOATDATA(double,VDOP,formatDop) + GWBOATDATA(double,DBS,formatDepth) //waterDepth (below surface) + GWBOATDATA(double,DBT,formatDepth) //DepthTransducer + GWBOATDATA(double,GPST,formatTime) // GPS time (seconds of day) + GWBOATDATA(uint32_t,GPSD,formatDate) // GPS date (days since 1979-01-01) + GWBOATDATAT(int16_t,TZ,GwBoatItemBase::TOType::lng,formatFixed0) + GWBOATDATA(double,WTemp,kelvinToC) + GWBOATDATAT(uint32_t,Log,GwBoatItemBase::TOType::lng,mtr2nm) + GWBOATDATAT(uint32_t,TripLog,GwBoatItemBase::TOType::lng,mtr2nm) + GWBOATDATA(double,DTW,mtr2nm) // distance to waypoint + GWBOATDATA(double,BTW,formatCourse) // bearing to waypoint + GWBOATDATA(double,XTE,formatXte) // cross track error + GWBOATDATA(double,WPLat,formatLatitude) // waypoint latitude + GWBOATDATA(double,WPLon,formatLongitude) // waypoint longitude + GWSPECBOATDATA(GwBoatDataSatList,SatInfo,GwSatInfoList::toType,formatFixed0); public: - GwBoatData(GwLog *logger); + GwBoatData(GwLog *logger, GwConfigHandler *cfg); ~GwBoatData(); template<class T> GwBoatItem<T> *getOrCreate(T initial,GwBoatItemNameProvider *provider); template<class T> bool update(T value,int source,GwBoatItemNameProvider *provider); diff --git a/lib/xdrmappings/GwXDRMappings.cpp b/lib/xdrmappings/GwXDRMappings.cpp index b2eb0c2..ad26bc8 100644 --- a/lib/xdrmappings/GwXDRMappings.cpp +++ b/lib/xdrmappings/GwXDRMappings.cpp @@ -355,6 +355,7 @@ void GwXDRMappings::begin() GwXDRFoundMapping GwXDRMappings::selectMapping(GwXDRMapping::MappingList *list, int instance, const char *key) { GwXDRMapping *candidate = NULL; + unsigned long invalidTime=config->getInt(GwConfigDefinitions::timoSensor); for (auto mit = list->begin(); mit != list->end(); mit++) { GwXDRMappingDef *def = (*mit)->definition; @@ -369,7 +370,7 @@ GwXDRFoundMapping GwXDRMappings::selectMapping(GwXDRMapping::MappingList *list, { LOG_DEBUG(GwLog::DEBUG + 1, "selected mapping %s for %s, i=%d", def->toString().c_str(), key, instance); - return GwXDRFoundMapping(*mit, instance); + return GwXDRFoundMapping(*mit,invalidTime, instance); } if (instance < 0) { @@ -393,7 +394,7 @@ GwXDRFoundMapping GwXDRMappings::selectMapping(GwXDRMapping::MappingList *list, { LOG_DEBUG(GwLog::DEBUG + 1, "selected mapping %s for %s, i=%d", candidate->definition->toString().c_str(), key, instance); - return GwXDRFoundMapping(candidate, instance>=0?instance:candidate->definition->instanceId); + return GwXDRFoundMapping(candidate, invalidTime,instance>=0?instance:candidate->definition->instanceId); } LOG_DEBUG(GwLog::DEBUG + 1, "no instance mapping found for key=%s, i=%d", key, instance); return GwXDRFoundMapping(); @@ -472,8 +473,9 @@ String GwXDRMappings::getXdrEntry(String mapping, double value,int instance){ } GwXDRType *type = findType(code, &typeIndex); bool first=true; + unsigned long invalidTime=config->getInt(GwConfigDefinitions::timoSensor); while (type){ - GwXDRFoundMapping found(def,type); + GwXDRFoundMapping found(def,type,invalidTime); found.instanceId=instance; if (first) first=false; else rt+=","; diff --git a/lib/xdrmappings/GwXDRMappings.h b/lib/xdrmappings/GwXDRMappings.h index 246ade5..7643bee 100644 --- a/lib/xdrmappings/GwXDRMappings.h +++ b/lib/xdrmappings/GwXDRMappings.h @@ -167,15 +167,18 @@ class GwXDRFoundMapping : public GwBoatItemNameProvider{ GwXDRType *type=NULL; int instanceId=-1; bool empty=true; - GwXDRFoundMapping(GwXDRMappingDef *definition,GwXDRType *type){ + unsigned long timeout=0; + GwXDRFoundMapping(GwXDRMappingDef *definition,GwXDRType *type, unsigned long timeout){ this->definition=definition; this->type=type; + this->timeout=timeout; empty=false; } - GwXDRFoundMapping(GwXDRMapping* mapping,int instance=0){ + GwXDRFoundMapping(GwXDRMapping* mapping,unsigned long timeout,int instance){ this->definition=mapping->definition; this->type=mapping->type; this->instanceId=instance; + this->timeout=timeout; empty=false; } GwXDRFoundMapping(){} @@ -195,6 +198,9 @@ class GwXDRFoundMapping : public GwBoatItemNameProvider{ return "formatXdr:"+type->xdrtype+":"+type->boatDataUnit; }; virtual ~GwXDRFoundMapping(){} + virtual unsigned long getInvalidTime() override{ + return timeout; + } }; //the class GwXDRMappings is not intended to be deleted diff --git a/src/main.cpp b/src/main.cpp index 599b340..17d2fa1 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -138,7 +138,7 @@ bool fixedApPass=true; #endif GwWifi gwWifi(&config,&logger,fixedApPass); GwChannelList channels(&logger,&config); -GwBoatData boatData(&logger); +GwBoatData boatData(&logger,&config); GwXDRMappings xdrMappings(&logger,&config); bool sendOutN2k=true; diff --git a/web/config.json b/web/config.json index ebc5a27..7dad6ca 100644 --- a/web/config.json +++ b/web/config.json @@ -250,6 +250,46 @@ "description": "the n2k instance to be used as port rudder 0...253, -1 to disable", "category": "converter" }, + { + "name": "timeouts", + "type": "array", + "replace":[ + { + "n":"Default", + "d":"4000", + "l": "default", + "t": "NMEA" + }, + { + "n":"Sensor", + "d":"60000", + "l": "sensor", + "t": "sensor" + }, + { + "n":"Long", + "d":"32000", + "l": "long", + "t": "special NMEA" + }, + { + "n":"Ais", + "d":"120000", + "l": "ais", + "t": "ais" + } + ], + "children":[ + { + "name":"timo$n", + "label":"timeout $l", + "default": "$d", + "type": "number", + "description": "data timeouts(ms) for $t data", + "category": "converter" + } + ] + }, { "name": "usbActisense", "label": "USB mode", From 3c664b1480c341cf0bf2d00df92674e1848a6480 Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Sun, 6 Oct 2024 20:06:15 +0200 Subject: [PATCH 16/58] #75: untested/intermediate - add some mappings for the pgn 130306 wind reference --- lib/config/GwConverterConfig.h | 71 +++++++++++++++++++++++- lib/nmea0183ton2k/NMEA0183DataToN2K.cpp | 32 +++++++---- lib/nmea2kto0183/N2kDataToNMEA0183.cpp | 73 +++++++++++++++++-------- src/main.cpp | 2 +- web/config.json | 52 ++++++++++++++++++ 5 files changed, 195 insertions(+), 35 deletions(-) diff --git a/lib/config/GwConverterConfig.h b/lib/config/GwConverterConfig.h index a3e9c81..546d885 100644 --- a/lib/config/GwConverterConfig.h +++ b/lib/config/GwConverterConfig.h @@ -16,16 +16,62 @@ #define _GWCONVERTERCONFIG_H #include "GWConfig.h" +#include "N2kTypes.h" +#include <map> + +//list of configs for the PGN 130306 wind references +static std::map<tN2kWindReference,String> windConfigs={ + {N2kWind_True_water,GwConfigDefinitions::windmtra}, + {N2kWind_Apparent,GwConfigDefinitions::windmawa}, + {N2kWind_True_boat,GwConfigDefinitions::windmgna}, + {N2kWind_Magnetic,GwConfigDefinitions::windmmgd}, + {N2kWind_True_North,GwConfigDefinitions::windmtng}, +}; class GwConverterConfig{ public: + class WindMapping{ + public: + using Wind0183Type=enum{ + AWA_AWS, + TWA_TWS, + TWD_TWS, + GWA_GWS, + GWD_GWS + }; + tN2kWindReference n2kType; + Wind0183Type nmea0183Type; + bool valid=false; + WindMapping(){} + WindMapping(const tN2kWindReference &n2k,const Wind0183Type &n183): + n2kType(n2k),nmea0183Type(n183),valid(true){} + WindMapping(const tN2kWindReference &n2k,const String &n183): + n2kType(n2k){ + if (n183 == "twa_tws"){ + nmea0183Type=TWA_TWS; + valid=true; + return; + } + if (n183 == "awa_aws"){ + nmea0183Type=AWA_AWS; + valid=true; + return; + } + if (n183 == "twd_tws"){ + nmea0183Type=TWD_TWS; + valid=true; + return; + } + } + }; int minXdrInterval=100; int starboardRudderInstance=0; int portRudderInstance=-1; //ignore int min2KInterval=50; int rmcInterval=1000; int rmcCheckTime=4000; - void init(GwConfigHandler *config){ + std::vector<WindMapping> windMappings; + void init(GwConfigHandler *config, GwLog*logger){ minXdrInterval=config->getInt(GwConfigDefinitions::minXdrInterval,100); starboardRudderInstance=config->getInt(GwConfigDefinitions::stbRudderI,0); portRudderInstance=config->getInt(GwConfigDefinitions::portRudderI,-1); @@ -36,6 +82,29 @@ class GwConverterConfig{ rmcInterval=config->getInt(GwConfigDefinitions::sendRMCi,1000); if (rmcInterval < 0) rmcInterval=0; if (rmcInterval > 0 && rmcInterval <100) rmcInterval=100; + for (auto && it:windConfigs){ + String cfg=config->getString(it.second); + WindMapping mapping(it.first,cfg); + if (mapping.valid){ + LOG_DEBUG(GwLog::ERROR,"add wind mapping n2k=%d,nmea0183=%01d(%s)", + (int)(mapping.n2kType),(int)(mapping.nmea0183Type),cfg.c_str()); + windMappings.push_back(mapping); + } + } } + const WindMapping findWindMapping(const tN2kWindReference &n2k) const{ + for (const auto & it:windMappings){ + if (it.n2kType == n2k) return it; + } + return WindMapping(); + } + const WindMapping findWindMapping(const WindMapping::Wind0183Type &n183) const{ + for (const auto & it:windMappings){ + if (it.nmea0183Type == n183) return it; + } + return WindMapping(); + } + + }; #endif \ No newline at end of file diff --git a/lib/nmea0183ton2k/NMEA0183DataToN2K.cpp b/lib/nmea0183ton2k/NMEA0183DataToN2K.cpp index 7868eaf..8bb9237 100644 --- a/lib/nmea0183ton2k/NMEA0183DataToN2K.cpp +++ b/lib/nmea0183ton2k/NMEA0183DataToN2K.cpp @@ -399,28 +399,29 @@ private: return; } tN2kMsg n2kMsg; - tN2kWindReference n2kRef; bool shouldSend=false; WindAngle=formatDegToRad(WindAngle); + GwConverterConfig::WindMapping mapping; switch(Reference){ case NMEA0183Wind_Apparent: - n2kRef=N2kWind_Apparent; shouldSend=updateDouble(boatData->AWA,WindAngle,msg.sourceId) && updateDouble(boatData->AWS,WindSpeed,msg.sourceId); if (WindSpeed != NMEA0183DoubleNA) boatData->MaxAws->updateMax(WindSpeed); + mapping=config.findWindMapping(GwConverterConfig::WindMapping::AWA_AWS); break; case NMEA0183Wind_True: - n2kRef=N2kWind_True_water; shouldSend=updateDouble(boatData->TWA,WindAngle,msg.sourceId) && updateDouble(boatData->TWS,WindSpeed,msg.sourceId); if (WindSpeed != NMEA0183DoubleNA) boatData->MaxTws->updateMax(WindSpeed); + mapping=config.findWindMapping(GwConverterConfig::WindMapping::TWA_TWS); break; default: LOG_DEBUG(GwLog::DEBUG,"unknown wind reference %d in %s",(int)Reference,msg.line); } - if (shouldSend){ - SetN2kWindSpeed(n2kMsg,1,WindSpeed,WindAngle,n2kRef); - send(n2kMsg,msg.sourceId,String(n2kMsg.PGN)+String((int)n2kRef)); + //TODO: try to compute TWD and get mapping for this one + if (shouldSend && mapping.valid){ + SetN2kWindSpeed(n2kMsg,1,WindSpeed,WindAngle,mapping.n2kType); + send(n2kMsg,msg.sourceId,String(n2kMsg.PGN)+String((int)mapping.n2kType)); } } void convertVWR(const SNMEA0183Msg &msg) @@ -460,8 +461,11 @@ private: if (WindSpeed != NMEA0183DoubleNA) boatData->MaxAws->updateMax(WindSpeed); if (shouldSend) { - SetN2kWindSpeed(n2kMsg, 1, WindSpeed, WindAngle, N2kWind_Apparent); - send(n2kMsg,msg.sourceId,String(n2kMsg.PGN)+String((int)N2kWind_Apparent)); + const GwConverterConfig::WindMapping mapping=config.findWindMapping(GwConverterConfig::WindMapping::AWA_AWS); + if (mapping.valid){ + SetN2kWindSpeed(n2kMsg, 1, WindSpeed, WindAngle, mapping.n2kType); + send(n2kMsg,msg.sourceId,String(n2kMsg.PGN)+String((int)mapping.n2kType)); + } } } @@ -504,8 +508,16 @@ private: double twa = WindDirection-boatData->HDT->getData(); if(twa<0) { twa+=2*M_PI; } updateDouble(boatData->TWA, twa, msg.sourceId); - SetN2kWindSpeed(n2kMsg, 1, WindSpeed, twa, N2kWind_True_water); - send(n2kMsg,msg.sourceId,String(n2kMsg.PGN)+String((int)N2kWind_True_water)); + const GwConverterConfig::WindMapping mapping=config.findWindMapping(GwConverterConfig::WindMapping::TWA_TWS); + if (mapping.valid){ + SetN2kWindSpeed(n2kMsg, 1, WindSpeed, twa, mapping.n2kType); + send(n2kMsg,msg.sourceId,String(n2kMsg.PGN)+String((int)mapping.n2kType)); + } + const GwConverterConfig::WindMapping mapping2=config.findWindMapping(GwConverterConfig::WindMapping::TWD_TWS); + if (mapping2.valid){ + SetN2kWindSpeed(n2kMsg, 1, WindSpeed, WindDirection, mapping2.n2kType); + send(n2kMsg,msg.sourceId,String(n2kMsg.PGN)+String((int)mapping2.n2kType)); + } } } } diff --git a/lib/nmea2kto0183/N2kDataToNMEA0183.cpp b/lib/nmea2kto0183/N2kDataToNMEA0183.cpp index 659a5c3..03a4a3c 100644 --- a/lib/nmea2kto0183/N2kDataToNMEA0183.cpp +++ b/lib/nmea2kto0183/N2kDataToNMEA0183.cpp @@ -469,38 +469,65 @@ private: unsigned char SID; tN2kWindReference WindReference; double WindAngle=N2kDoubleNA, WindSpeed=N2kDoubleNA; - + tNMEA0183WindReference NMEA0183Reference; if (ParseN2kWindSpeed(N2kMsg, SID, WindSpeed, WindAngle, WindReference)) { tNMEA0183Msg NMEA0183Msg; - tNMEA0183WindReference NMEA0183Reference; + GwConverterConfig::WindMapping mapping=config.findWindMapping(WindReference); bool shouldSend = false; // MWV sentence contains apparent/true ANGLE and SPEED // https://gpsd.gitlab.io/gpsd/NMEA.html#_mwv_wind_speed_and_angle // https://docs.vaisala.com/r/M211109EN-L/en-US/GUID-7402DEF8-5E82-446F-B63E-998F49F3D743/GUID-C77934C7-2A72-466E-BC52-CE6B8CC7ACB6 - - if (WindReference == N2kWind_Apparent) { - NMEA0183Reference = NMEA0183Wind_Apparent; - updateDouble(boatData->AWA, WindAngle); - updateDouble(boatData->AWS, WindSpeed); - setMax(boatData->MaxAws, boatData->AWS); - shouldSend = true; - } - if (WindReference == N2kWind_True_water) { - NMEA0183Reference = NMEA0183Wind_True; - updateDouble(boatData->TWA, WindAngle); - updateDouble(boatData->TWS, WindSpeed); - setMax(boatData->MaxTws, boatData->TWS); - shouldSend = true; - if (boatData->HDT->isValid()) { - double twd = WindAngle+boatData->HDT->getData(); - if (twd>2*M_PI) { twd-=2*M_PI; } - updateDouble(boatData->TWD, twd); + if (mapping.valid) + { + if (mapping.nmea0183Type == GwConverterConfig::WindMapping::AWA_AWS) + { + NMEA0183Reference = NMEA0183Wind_Apparent; + updateDouble(boatData->AWA, WindAngle); + updateDouble(boatData->AWS, WindSpeed); + setMax(boatData->MaxAws, boatData->AWS); + shouldSend = true; + } + if (mapping.nmea0183Type == GwConverterConfig::WindMapping::TWA_TWS) + { + NMEA0183Reference = NMEA0183Wind_True; + updateDouble(boatData->TWA, WindAngle); + updateDouble(boatData->TWS, WindSpeed); + setMax(boatData->MaxTws, boatData->TWS); + shouldSend = true; + if (boatData->HDT->isValid()) + { + double twd = WindAngle + boatData->HDT->getData(); + if (twd > 2 * M_PI) + { + twd -= 2 * M_PI; + } + updateDouble(boatData->TWD, twd); + } + } + if (mapping.nmea0183Type == GwConverterConfig::WindMapping::TWD_TWS) + { + NMEA0183Reference = NMEA0183Wind_True; + updateDouble(boatData->TWD, WindAngle); + updateDouble(boatData->TWS, WindSpeed); + setMax(boatData->MaxTws, boatData->TWS); + if (boatData->HDT->isValid()) + { + shouldSend = true; + double twa = WindAngle - boatData->HDT->getData(); + if (twa > 2 * M_PI) + { + twa -= 2 * M_PI; + } + updateDouble(boatData->TWA, twa); + WindAngle=twa; + } } - } - if (shouldSend && NMEA0183SetMWV(NMEA0183Msg, formatCourse(WindAngle), NMEA0183Reference, WindSpeed, talkerId)) { - SendMessage(NMEA0183Msg); + if (shouldSend && NMEA0183SetMWV(NMEA0183Msg, formatCourse(WindAngle), NMEA0183Reference, WindSpeed, talkerId)) + { + SendMessage(NMEA0183Msg); + } } /* if (WindReference == N2kWind_Apparent && boatData->SOG->isValid()) diff --git a/src/main.cpp b/src/main.cpp index 17d2fa1..714c0a5 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -850,7 +850,7 @@ void setup() { xdrMappings.begin(); logger.flush(); GwConverterConfig converterConfig; - converterConfig.init(&config); + converterConfig.init(&config,&logger); nmea0183Converter= N2kDataToNMEA0183::create(&logger, &boatData, [](const tNMEA0183Msg &msg, int sourceId){ SendNMEA0183Message(msg,sourceId,false); diff --git a/web/config.json b/web/config.json index 7dad6ca..906920a 100644 --- a/web/config.json +++ b/web/config.json @@ -250,6 +250,58 @@ "description": "the n2k instance to be used as port rudder 0...253, -1 to disable", "category": "converter" }, + { + "name": "windmappings", + "type": "array", + "replace":[ + { + "n": "tng", + "l": "true north ground", + "t": "True_North=0", + "d": "twa_tws" + }, + { + "n": "mgd", + "l": "magnetic ground dir", + "t": "Magnetic=1", + "d":"" + }, + { + "n": "awa", + "l": "apparent angle", + "t": "Apparent=2", + "d":"awa_aws" + }, + { + "n": "gna", + "l": "ground angle", + "t": "True_boat=3", + "d": "" + }, + { + "n": "tra", + "l": "true angle", + "t": "True_water=4", + "d":"" + } + + ], + "children":[ + { + "name":"windm$n", + "type":"list", + "description": "mapping of the PGN 130306 wind reference $t", + "label":"wind $l", + "list":[ + {"l": "-unset-","v":""}, + {"l": "TWA/TWS","v":"twa_tws"}, + {"l": "AWA/AWS", "v":"awa_aws"}, + {"l": "TWD/TWS","v":"twd_tws"} + ] + + } + ] + }, { "name": "timeouts", "type": "array", From db3d8323eaa4f6be1c2614967bef385b38df2b6e Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Mon, 7 Oct 2024 19:57:19 +0200 Subject: [PATCH 17/58] config for wind mappings working --- web/config.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/config.json b/web/config.json index 906920a..69df271 100644 --- a/web/config.json +++ b/web/config.json @@ -297,7 +297,9 @@ {"l": "TWA/TWS","v":"twa_tws"}, {"l": "AWA/AWS", "v":"awa_aws"}, {"l": "TWD/TWS","v":"twd_tws"} - ] + ], + "category":"converter", + "default":"$d" } ] From 465c66a514fab87222ff6e5cf8da8e8b6718df8d Mon Sep 17 00:00:00 2001 From: free-x <oroitburd@gmail.com> Date: Fri, 11 Oct 2024 10:54:00 +0200 Subject: [PATCH 18/58] Issue #83: Converting MTW from PGN 130311 --- lib/nmea2kto0183/N2kDataToNMEA0183.cpp | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/nmea2kto0183/N2kDataToNMEA0183.cpp b/lib/nmea2kto0183/N2kDataToNMEA0183.cpp index 03a4a3c..a64e498 100644 --- a/lib/nmea2kto0183/N2kDataToNMEA0183.cpp +++ b/lib/nmea2kto0183/N2kDataToNMEA0183.cpp @@ -1332,6 +1332,21 @@ private: return; } int i=0; + if (TempSource == N2kts_SeaTemperature) { + updateDouble(boatData->WTemp, Temperature); + tNMEA0183Msg NMEA0183Msg; + + if (!NMEA0183Msg.Init("MTW", talkerId)) + return; + if (!NMEA0183Msg.AddDoubleField(KelvinToC(Temperature))) + return; + if (!NMEA0183Msg.AddStrField("C")) + return; + + SendMessage(NMEA0183Msg); + i++; + } + GwXDRFoundMapping mapping=xdrMappings->getMapping(XDRTEMP,TempSource,0,0); if (updateDouble(&mapping,Temperature)){ LOG_DEBUG(GwLog::DEBUG+1,"found temperature mapping %s",mapping.definition->toString().c_str()); From c1ac1baa1b67ca7bf42564b20c4ee2263be4560f Mon Sep 17 00:00:00 2001 From: free-x <oroitburd@gmail.com> Date: Fri, 11 Oct 2024 12:00:39 +0200 Subject: [PATCH 19/58] fix ci for ubuntu-latest --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2562875..0a9999c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: run: | #apt-get install -y python3-pip python3 -m pip install --upgrade pip - pip install -U platformio + pip install -U platformio --break-system-packages - name: Build run: | pio run From 61e2c93b7336b2237a41b566f35da3bcb8b515b3 Mon Sep 17 00:00:00 2001 From: free-x <oroitburd@gmail.com> Date: Fri, 11 Oct 2024 13:00:41 +0200 Subject: [PATCH 20/58] fix ci part 2 for ubuntu-latest --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a9999c..ac78ae5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - name: Install deps run: | #apt-get install -y python3-pip - python3 -m pip install --upgrade pip + python3 -m pip install --upgrade pip --break-system-packages pip install -U platformio --break-system-packages - name: Build run: | From 5cb8bdd8c74161e53bcb35e6a57a510ac260dd52 Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Fri, 11 Oct 2024 15:51:05 +0200 Subject: [PATCH 21/58] only set --break-system-packages as environment to be compatible with older versions --- .github/workflows/ci.yml | 6 ++++-- .github/workflows/release.yml | 3 +++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac78ae5..050f76f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,8 @@ jobs: os: [ubuntu-latest] runs-on: ${{ matrix.os }} + env: + PIP_BREAK_SYSTEM_PACKAGES:1 steps: - uses: actions/checkout@v2 with: @@ -17,8 +19,8 @@ jobs: - name: Install deps run: | #apt-get install -y python3-pip - python3 -m pip install --upgrade pip --break-system-packages - pip install -U platformio --break-system-packages + python3 -m pip install --upgrade pip + pip install -U platformio - name: Build run: | pio run diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f7a26bc..52b2fd4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,6 +18,9 @@ jobs: # The type of runner that the job will run on runs-on: ubuntu-latest + env: + PIP_BREAK_SYSTEM_PACKAGES:1 + # Steps represent a sequence of tasks that will be executed as part of the job steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it From 0cd552d590de0a77175207628b2f4f11aa7400ec Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Fri, 11 Oct 2024 15:52:28 +0200 Subject: [PATCH 22/58] correct workflow yaml errors --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 050f76f..c373801 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: runs-on: ${{ matrix.os }} env: - PIP_BREAK_SYSTEM_PACKAGES:1 + PIP_BREAK_SYSTEM_PACKAGES: 1 steps: - uses: actions/checkout@v2 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 52b2fd4..2aeae17 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest env: - PIP_BREAK_SYSTEM_PACKAGES:1 + PIP_BREAK_SYSTEM_PACKAGES: 1 # Steps represent a sequence of tasks that will be executed as part of the job steps: From 09b583ebd68b0f8b4aba871e40ae45050dc93173 Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Fri, 11 Oct 2024 16:14:22 +0200 Subject: [PATCH 23/58] also set water temperature (MTW) from PGN 130312 - with condition --- lib/config/GwConverterConfig.h | 2 + lib/nmea2kto0183/N2kDataToNMEA0183.cpp | 16 ++++- web/config.json | 99 ++++++++++++++------------ 3 files changed, 72 insertions(+), 45 deletions(-) diff --git a/lib/config/GwConverterConfig.h b/lib/config/GwConverterConfig.h index 546d885..d0dbc6a 100644 --- a/lib/config/GwConverterConfig.h +++ b/lib/config/GwConverterConfig.h @@ -70,6 +70,7 @@ class GwConverterConfig{ int min2KInterval=50; int rmcInterval=1000; int rmcCheckTime=4000; + int winst312=256; std::vector<WindMapping> windMappings; void init(GwConfigHandler *config, GwLog*logger){ minXdrInterval=config->getInt(GwConfigDefinitions::minXdrInterval,100); @@ -82,6 +83,7 @@ class GwConverterConfig{ rmcInterval=config->getInt(GwConfigDefinitions::sendRMCi,1000); if (rmcInterval < 0) rmcInterval=0; if (rmcInterval > 0 && rmcInterval <100) rmcInterval=100; + winst312=config->getInt(GwConfigDefinitions::winst312,256); for (auto && it:windConfigs){ String cfg=config->getString(it.second); WindMapping mapping(it.first,cfg); diff --git a/lib/nmea2kto0183/N2kDataToNMEA0183.cpp b/lib/nmea2kto0183/N2kDataToNMEA0183.cpp index a64e498..1056c87 100644 --- a/lib/nmea2kto0183/N2kDataToNMEA0183.cpp +++ b/lib/nmea2kto0183/N2kDataToNMEA0183.cpp @@ -1344,7 +1344,6 @@ private: return; SendMessage(NMEA0183Msg); - i++; } GwXDRFoundMapping mapping=xdrMappings->getMapping(XDRTEMP,TempSource,0,0); @@ -1379,6 +1378,21 @@ private: LOG_DEBUG(GwLog::DEBUG,"unable to parse PGN %d",msg.PGN); return; } + if (TemperatureSource == N2kts_SeaTemperature && + (config.winst312 == TemperatureInstance || config.winst312 == 256)) { + updateDouble(boatData->WTemp, Temperature); + tNMEA0183Msg NMEA0183Msg; + + if (!NMEA0183Msg.Init("MTW", talkerId)) + return; + if (!NMEA0183Msg.AddDoubleField(KelvinToC(Temperature))) + return; + if (!NMEA0183Msg.AddStrField("C")) + return; + + SendMessage(NMEA0183Msg); + } + GwXDRFoundMapping mapping=xdrMappings->getMapping(XDRTEMP,(int)TemperatureSource,0,TemperatureInstance); if (updateDouble(&mapping,Temperature)){ LOG_DEBUG(GwLog::DEBUG+1,"found temperature mapping %s",mapping.definition->toString().c_str()); diff --git a/web/config.json b/web/config.json index 69df271..ca155ec 100644 --- a/web/config.json +++ b/web/config.json @@ -219,16 +219,56 @@ "category":"converter" }, { - "name":"checkRMCt", + "name": "checkRMCt", "label": "check RMC time", "type": "number", "description": "start sending RMC if we did not see an external RMC after this much ms", - "default":"4000", + "default": "4000", "min": 1000, - "check":"checkMinMax", - "category":"converter" - }, - { + "check": "checkMinMax", + "category": "converter" + }, + { + "name": "timeouts", + "type": "array", + "replace": [ + { + "n": "Default", + "d": "4000", + "l": "default", + "t": "NMEA" + }, + { + "n": "Sensor", + "d": "60000", + "l": "sensor", + "t": "sensor" + }, + { + "n": "Long", + "d": "32000", + "l": "long", + "t": "special NMEA" + }, + { + "n": "Ais", + "d": "120000", + "l": "ais", + "t": "ais" + } + ], + "children": [ + { + "name": "timo$n", + "label": "timeout $l", + "default": "$d", + "type": "number", + "description": "data timeouts(ms) for $t data", + "category": "converter" + } + ] + }, + { "name": "stbRudderI", "label":"stb rudder instance", "type": "number", @@ -305,44 +345,15 @@ ] }, { - "name": "timeouts", - "type": "array", - "replace":[ - { - "n":"Default", - "d":"4000", - "l": "default", - "t": "NMEA" - }, - { - "n":"Sensor", - "d":"60000", - "l": "sensor", - "t": "sensor" - }, - { - "n":"Long", - "d":"32000", - "l": "long", - "t": "special NMEA" - }, - { - "n":"Ais", - "d":"120000", - "l": "ais", - "t": "ais" - } - ], - "children":[ - { - "name":"timo$n", - "label":"timeout $l", - "default": "$d", - "type": "number", - "description": "data timeouts(ms) for $t data", - "category": "converter" - } - ] + "name": "winst312", + "label": "130312 WTemp iid", + "type": "number", + "check": "checkMinMax", + "min": -1, + "max": 256, + "description": "the temp instance of PGN 130312 used for water temperature, use -1 for none, 256 for any", + "default": "256", + "category":"converter" }, { "name": "usbActisense", From f6e3fa369f73134a077e1ae504eaed10b45109de Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Fri, 11 Oct 2024 19:23:19 +0200 Subject: [PATCH 24/58] intermediate: prepare for js api --- doc/Conversions.odt | Bin 17203 -> 26103 bytes doc/Conversions.pdf | Bin 38012 -> 38259 bytes extra_script.py | 16 +- web/config.json | 2 +- web/index.css | 3 + web/index.js | 3859 ++++++++++++++++++++++--------------------- 6 files changed, 1976 insertions(+), 1904 deletions(-) diff --git a/doc/Conversions.odt b/doc/Conversions.odt index 975520d39cfa3f817e319c8b3ccfb907fedc8985..9ca2adbc44e5f7c038bb02cadeb73559c7fe439b 100644 GIT binary patch literal 26103 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 M@MY6+)%{ufAD$UL@c;k- literal 17203 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 diff --git a/doc/Conversions.pdf b/doc/Conversions.pdf index 75b2bd240efac709d17fd418b357f06432366c93..3de736be458ce876b404e60dad7c020d55feb8b1 100644 GIT binary patch delta 32931 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 delta 32701 zcmZ6yV~{RP4=p;jZQHhOYmfKXww^Ke*tTukwr$&<x$jq}Zq=#tD_NCv`cHSSB;Ea3 z0rFJ_0!6MQCPB~4z!ncB3XrZnVngbAtsTQYXLs|AUi$asC3*nC7Xf+z0g3}JHD&rE z(9C`9f_EBVD6yC*lRDYlenH#Hy|F*JU)#U8%M?lqx4vmiAn@z;c3-(a8^nDQ4hTK{ z75sIBI%I|;4mHO4;aWS2)h|~JQeh~3QVe2ZfZFH%@_V~m&j0XA0TfQj1PFF`o(Ccc z#2YVq;F*UQKCwPqD$!_tk<}k&RH!?ROs#x5-f^h4P!~rgJ4-JY@TJLA?uis9t8{2_ zr!Gw>b5O#mI3_6#D`RjQ-A^YV^=v4M$F-F}snBqvY3e?TEf);!k;v$(iv9lq{x1ml zUqG^Kc=Gf2Yb25rXBbD1Pt&&#yLfc&U4btb7B??PO3*}pY)QhLbD>zrz(ncANOFd+ zu|?Q3&BLe1&DOBZ&Hc_3i5R>woqoef35dJgL3pL`!O@2~bacsyn*!G&bCeeu>)FLf z!F1Sj=WL~O+)MY|uW=l&rWAv$2tYvF6aAeIdyFk9v!~JK);Q0JQ>>&SL*3u{p$VAd z@S!ZNn%m4o6tjaw^FS&SF(6u&f{>%vKkiL&Jzevua=Y_5%*@1{;FoucI!x}mQ!5Z3 z1g4NE?wS86#o~Ne461fxEg;gPWY6ejOmnIN-?p{dvh03j?3F>ca(7325CMUsk6ljz z{Ee}HyuIi7U@5)nD;hC4y$kVOoz-+*N7Sp*FlD76cp=q#VIxEsya8I#4LL{t`_(qd zn+2b*yK&f(sG*EfzPxMuq<<1Z&wwAZ5Ug^_6$cTG+=2v$qDGtVV3&^}bskxlk2(9` z#op5|?mv-e{PH`#D6IcZumYA3-KnRxp0<wRi+PXIFCIF>`5jKoD<<uAi#wJ0{rYAh zdzJM39FiGBF77w^G}=kkQgdf#re@VrcZ+G<gD)O7b<Y{qVg)~Cd?~!P)&>p_^T6yT z@a<;fqj|eHlzUOld~X~E)6C{Sw)S7Hfc95Tb5<-g>8vIn13MW4f&o$_RU5_wIHq;s zcF78v^4{3bgf-ScTHXG*C@tEM1Oq8dfdu))2qck<9TqJq-gWeP1xvHI?gSSz_fKcM zbhUi3Drub$^uYI&&kIS48w3p;9HHoMIP;Otin&Nc?ypR0%DJyXyiJg_CdhxWvXFn> zz#+s8>x<dC6g-!9W&kR{m8=!5jL69PH*Ep+pe@q#SQ1vwTdd=}I?**7j_xggsdDbw zSCDcdLfPAjk`UIGbK~K&_5-Egpb%aTD!?VhFwKWeg&S|4q4jPqMi&twpw-uu=Q7c+ z{4Y(abCK0iiUK@o-vZbJL7a*5b0bjgVDU7B^B)D3vdr7+E&&?hv{dl!BcbkU*BVV4 zf-~Cvw!F1sb(TcRG7qdT5<$`xNN9Fwf-{*;{-@6T3LM#4*;x{`SuYCE*0z_1H0gB5 zlLt33xp?r<cBrbd_|_RJ`{wg)BEwb)hH|&6eB&B0y^>BK6UB?|e+1u6INVH&s4cQM z12c;h>Y3sKx&dWxS(@sx=rC$5bry68ahFqn>7pqQD9t682FS%Hpsw<d>M=}hsg>}7 z4Q43b?^Rf?9r-$V#1<JMjKU9N4j<fFR92d{8gzG8`!{1W@12uIG^76=7MeN1I3*Zc zCSM4tPegm~gf+eV(4ImNlJWFrVvlHWE~S8yp!b=|W&;#mPlMfSzY7;o&S#dz#t6>v zU}1Ok&U|jJ7VEx=10J<ltR<sdE06l!AfAzE1D$~JyTjY!t?YOax4;?7mY4PD*_F8o zy3d+|{|3bsw0lwv)vRdC)u$=mXvieCGpbQH-o)(LtQ5y-7S!Q(-sm4PwOW!Sx0OVI z`w6$Z#Q~B$RayzdEGCO*KKS&J2po;6r#)S*9dj-{ZnxZwSCxdsmE+x<F)Q?Cy8m?f z><`R2?^zK(>~4<Q&fX0_mY72c=@Fv0xp2S*N#+XA@f_D+9Z#c~K(f8`Z^hm+SvDu! zEO=1>H^~BLg88AM4wT#RersRK>AR=92X^Mv(*m~L4<NZlTWR4|Fcn80k|f^CBeady z*b!B<f~<uDjIzI8v0A2lbTnxo$?eAR@u6ODunb=WNX=|80>Gk@X+Ohjk5RnQuL;d% z;t~~DyW`k~yS*I;W2<D<)KBW2K4B915A#vY=!Sn;rs94hnP`)G&X23*j=hq+0<5r_ zvj8leHMX@ov5{JKBk;i|3^Pdj9#J?K5KT9ZlnkZbAuxuRB%91twLm|VskhO=57sg4 zmgW>q=j}@om2IvC{o%0e=M!{Vq3tZhDT$HMM&Pt=%}W(MybhaC)^mR7j2J`<@bJ5( z_Sh2^@_y`4HsiXB9%e_jx(nr5={)7T8-R=N^4|K7>~i~lc1TC|MOD7ad}|1MG|lWa zjQVDE3vUX}mTyRkR?Xg3lf%qX{Ah70p<`9~j%47R`0Qkwz3HS2ZkM0=(#}(h6{@3m zbJFfow7z`IWPVJJDWLYh#ze=Qk2-x`&fP+@$MnO89p%Azt(a@qHIL6z2Ug8${)*Y2 z`TOYJ%i|x-T)H*`2iEYyhx*!8bQ}G$<?Ij+I9YmY(5*jq70<yv58IoM&-#ITtlB9m z>Ld4E(jWKTPGt}`3@i$pQoo)1_N=(dFNv~HxM0lOTwIA(P(lE0*;rgoxSmhVsdj%o z<ycRk5unRve4^GS*e9i@B}^nX@C?}>KWUkac@L@?E@+HP{Pm=_H-Y%1t`o57)ImUW ze=bvSqOBKVY0vxD>3%9<1^_Ws@ckS=B1_JqXYb4xbb3_OLQ!w=Ise@Ssz!14mramR zV98|D!OGl{pd9cplk;!cvuM0^(7`Fu2e16WHu6T{fluXFufAhb3m30PVS#VqNYoz2 z;7}_0b$fjF;Xmk$MPccsVQR~T{y*ckA?*K*h@$;LTq?)?DgX1JXR&Ufj8XGEll3I9 zeDI&jFea3&_zE<y8)tPu;5;UqB~|z9XJ0!4Z0x~4dgk2>7{S_I-3o|x>ZoIMAb`e& zESAyOhSg~CL{}^j^an}vL|<qWYF^v8;h`^lJ-*)#9$)v7UGoTyw+dzu-nIa5c6~oz zu5*E|;5zj8Wb_B$Tg=Wy8_t|e%$o6qHhSXk!y)M3#kNO{zSuyVN`9@0@cz8K{5W_6 zbk2i!!fEIkqZT`FuH<c7T8X9v1~1=UloMhJAo|{F_+3V{o>2LlUhl(e*6L>w+>BpX z1hk(}r+`+^p6Gjcm#(6`^unag%2)M9gcc!nAwffF=!O+wQw+PEskOdT(xYzv_9U0+ z<RApNNV?OLxJS=gzz;|hTP6}L-e2|ss@k}|jG54Itu%WQ6TEfFAa>GWEZ`apISS<5 zo=jf+J>upgh4M&)k^hSKtZxQsVm()^SE&Kffm}m*wKX<i;=AO*Ym#q*%X<lDw5P28 zyT|V`&7Fdsuls^Z5|VTsF*bNKd;pbJJ`TZ*Yzoq=Lfw+mL6c}v2s6m&8RuaEJV$u2 zn$U{lj^_;zomIZ=(K^zY%&-!jv)e(lbz~5$6=}=q&>A_I(KC52GsVj(;`+<Apc=1q z8u7U`nb(YU@hc~@U2prM+JL82Fc35Bc~;;PSPJ`@ypWK&uMY2l^FQ?)t<o9W1;IP7 zAE=~g6v1#t%^@G^iL7rG*wwfKIuz)-_K~Yn5_$=NEstb(Dxd9azcGgYBGK}+U+QnG z&71G+Gjx?tC2GK_atIAID}mWi5IU<hKz70*{6@B8E^~Hc^p8&TkDv(Hh1;ySzEnO9 za=$42lUvqz(qQ3C$ju&hMXOs$oGn@IgIVL&ya0y?>%X9=)5Uxh<`v}xQ0C$b3sY44 zd(ePOuPE^LXo5Cow4-D3k?`5&1+puGNmQ#P;5?0WSkJIFty1$FYQmUT0moPgPe`Vf zl~uM!J3~zd)-98RZ)wKr#kj}6mDN}uR=k83Z<;07zmA|y<sL_YJ=V;tXVl#|^4<Q^ z(N6n%L3-R)H<&(b_ejP95YTtbz*#%rM78XyHvYK<;aGD2rm44IWS+MD&M-I^OJtQh zE3XK3<|Aw)xMsL=aVD2Zrp^u)iokW={8XBpd%AR@O{)}LCms3ribZ~izo;{Q^oA6v z>giTZ@`=bc;^DLl4lIERsH^2D+s3LPP^q1Y{=<J{b1Wl&(QV)WsMsDuz8sH03b7UC zz*TLL2b<w#aYu7LtUN?-?#LIcHbQledQHpG0^OKDwJj{(mBi%Gh6=-#WmsSD=V7?N zoytKAo~Fk~Z;kavX<zzS@{P|`xK<+@lr5?x%O|R)v=14F+&2T}#fgkQ4ngpqQ~^Q7 zKEX2{Y-l*!@0b4qq;*HC3Bx-^Jtjps*&)eEi^D%MUqIY#mdcO1D<aN6x34L<i|U31 zj#Hwrp17;7T&%;<aCp_22!$XS%k)om%}3YwyPcu?>%QFpt?vkeBVmf(uDCH_)Wfe; zYIlrf>J$~wsnYMO{4P^!i%9&lZAnQmem0`f#uXZ4LdM1fl-+*sjI||q@V8YZh5PKP z2UI^-1`E*hXJNu$ALOOW0D6jb2bv2PO`C!}1OrkWrqSjPb|;6_Zy%zuT~TN88OX@% zQ;kJcd4=8K9y8PC>PzH0$vFtVq`PnP)EF<ccO9<u6AQt7kz(kmWnwBQ2u1qI8<bb0 zS9VL((sU*O!|=fs^{964x2nJjqbHu1sg|!bl=|c8X!%R4(@G9BBT(=P)x;}dGD-ft zT>9?oQNW6lUH{5^0X$ewAF|q?rS6b)s8833W(Iz7J(omt=&2}Yu=aVH1GAUV^P|rL zJu{d?I^|;+9WaiC&Py)SMf<EHfn3MRCLa=Jn2y1K%ko=sy#88t#|k`PA+TX~vWj8O zcRD3vg?jyLuIk~bDiujLE5;>{83RJ+k|Cf)b<NBSPXxX#r>@D!j;(uZD5#4Z2_fx6 z9i+YI9&q6G@@%MZ%{XkJj=&}t&dJw2)nC>r@|B9Pn2V789|u`ko0B2W6U|bw^`~s% zW55|_8F;dGn@fO`oUeL`!`B5Xn!J1ubjL{07c+~Vd3)aXdYP>3RV2^s*G1W6iLCPP z+!vz9z%FJYHxeEw7b{1i7LqVPx{{Rhe+BZHbDx5AaWS}jQ0i&l76tPWlG6l)C`DX* zS8uLLIVQg~F6%c`thvs$itTP`MZ!MS$Lh^4!I>hjZ_;<(kmFhJ*ZlXF-mfC$*L19) zM?C4-PqGG~*F4~&iF;!Qq2qVU67OUd@G9s^DBG|>t6%J?o{Xt)mkc;nm*0W=X!AtX zQ1_&dwMslyS0`|xo2|^~S`j@`)KWh#7C4~zA8hzPP<iN~t77~xkK}i-f+m(D<-hUz zJX{&-0#y}0L}DSSob--=>5)C-5i2hs&TUMHsw!~N>XoZ-2Pz1F!7W>mJf7<UetW)_ zvv0HA^w(ejC_TSB06=1Q-1qmafrL=G@Pgn3Yn${M@~}xL@=PMuzG*_C2XwBzLp~?) z6I883CPHHzQq%3~XV}fLK2s$<@@@4WYUyZ~DtZgy{Q*c${3|F0-5`Fn&>YOCNZ<zL zh)EZs;f>yZ*3+WYeAbOTc7B|uqA@TOVR#lEQvaR-RLm>XT9kh=&Ofi6A%j1ydsq0* z=5y0_q)3oPM#-I|rIlf1YMGZ=6(om`1A6jOXn^Qz6OpVWJ|-MPG(&~GP4Kcxg0!fS z?kxN1)em@bg0sfSiu%AEC3>-7f-)n-8dB8+@@^ohE+Av2ff{b!XQbzXB<I6yMDq5= zRAasX4{1r_+L!h5K!*f_shl$fS)qS2>jW^F@SncsDTh^aP(`@|GpQs-J;%W?9|pGI z*i3gI^HLe-lA2Vs%eu^-1&S;*q$&IAlAS%K-bzdkL~RaE5$fDXf=De<0$%9I|JER@ zq$b^A%NX5Dp8b@=;o<YBn!t4njAP+x%jHi1(oDSFYmNpsf@uGAcRGe>k*LG(Ecw9d zKY^+e=y&;V5$ab2znqVxlKPMC+=fFbwbBt$XU;NFB$n%8B`$VS8rSWhG@-IX80G+P zHpcn2A`uCZ;A&Y|qhk2$bxYzlW!QFUE9XG}HNR}+-l5ewT7>8g0clse77nP|!KWqy z09{%tS129E)9^)0y^}cTn+x-a+m?j@i|_=VO<y)c@u|-`6JLSIE8OmM=f?agd^Asd zX}Lum`Ip2VcOu;t%=URN@R(b$sV`E`BsqvCDv0@_YG^l9?#U&YcgSGS!bTvs_~I}9 zfMIn$#<i}Op%87Ej&?K9l(qBy&%bL4a1y%YVQi-)f!+a;8Z{suxHG-LQA5APRunX_ z;#fwMRe;-;asQYYcn_b?La{Pk@ScJZKRHQoHFmKbiyw_G46P%S8JF^dq9U}}3+W>Q z@eN%%xakkXB;4bt?|IXO%<~luJGEA^JP=)>j;9OrW!wHKO6<u_W0LTKanFYi7_-QR z;qEq&3~}Ro9Mhu1AXdkKc?>Ka<lTZ+&MXR~l~;wV)G+&&r#L5HXhpF@9*^qxW4o#t z-95{p0&l0gR)%TtvcFU|ZUxysJju%3e<ZWiniZO_U?To4n4;MYT&TmLhaxYbgTotg zZ);OId^6yp+BS}xbzn*?)Z<DMTg*HpDrE;5FxcB&nBCosdOf~8OBMc0_(6_5{Pel@ z3%Snp3=U&zXX5PQWct_kf116K6$}hF5fjn>6lP{FwnQ^@aljGB|BkUcZYKO>f)w!R zza^XaYnv3H+`(H9{^ZamjGwQni0c|UPVJ!UOi|`b4COlp>gChe9gL;Dyg`D*A&u7% zh^P6VUzg{j_8q+sxQo6&-%l@oF#J9L=Cf5_e`H^;x7`doUw8dF+BEC-blL1lb*Hws zMlihtxDG%C0jb5(6c3~BUfPk2)rXiXT$i?Yj(7~-Z2oF*tUq5r`)u900y$zex4opu zvlK1{4$t{PH>lFsomdC!?tq*?+>~X)ZN#>E`Q6f=Eykw?m2+=*1X})DHLu(GCD{MT z%lVc6B-=+^UYvmjjV~6=yJ_)4r`i}JZSoG<0su=7Lolf&HCcvGW^+=e&>eWjF8@Dd z?}N<SuyAGzCYP12uR+5v6Q|*&Z~25|Ox#<g_E9H+@LfClExJaimGclzze}i;80SWu znk-?jI+V)VUB%{w2nw+$$4IO0wPeq4pb`5-J~bbCMqNel7onf}>Tva+eC%ky;fsPd z0O5il)_G)4xf;~Acc2Ee(cQmwE~S(c)i#@Gk1+Tl^=BZLsp2u>@mHFElOif6+9LSc z!Sp-yo?MBYh(Ca6H+sf4hht6n7&xCsgrfjr&?2?*bP#SWSnwlY#0csd7Jsd&)u%^3 zu5OB)4e{KGJX7v1QGQo;c=L1&c?Gcr(2_U^)rf`S;Mmzoou{2Ctr~@pXUI0*ox!`N z>+K9qVB5xprI$zIOAa|mJ8_e+{!GPs8Md*3R__6G-*#EwQAmZ&7G>Dr;-TQwN>?tH zF1=oGC`(qkJNk1#CT*l4Wv)A}w>G9X`l|T(UvF${jk9V7cv-)C8ZDFnu#3k7Fc0>) zH;eK`$RTf@OJM{RchI_Ky0`9~OgLyYla8;XK7%uIDkfqP-=3qcyHs1LYu1so-d@(j z1jo3ZYfj8TFudqVSP<rm0WKV0T;rP@oNXF{ANiqeXA(^0a+yJc=0fqXG+^%mrd`J1 zXvsJPcqO%<onFEGxfk+~_6(f`;QmEfg_FA*(!FyAz6^4)fbl-XdfeI8+V5IH?n!wg zS_9z#(g8yX;}U!jZFDq#ywLIX9S8vmB!b$*u6&P%@yfTK;<leM419`f`B!eWA1fD9 z*pI=>1y82`%w}PWp}JlPZ-sNTb&DA;YHa1Do)z=dkS`LYQmbcIrD^;KsKN}4UVcjG zt?YE7o=O&H^Z^F){*^!OP!^DBP^xIt(=z5&Pas?yFO?t_(H}=p`QF`_a)ZgD(tts< z0@`!#rv3z-`69YbXG;_r9FdiEFFe<gbttm8VS}vf0dW=bHA=ClQO>}|G5-OWA=TNA z``;nUP++w%lXB&BxFo~^mcs&M^|_RAnx{CHs&Q@(grZTJ(nn>f>?}|`ZjMUCFAx2V z-8Hod+BcW0k(CTu(Rin8qo>y{w7;@-zK49Nx5ba0MlK?>kFiLn84-DCKn!Vp30mlG zVT&Aa;ZqZY=O>Q6z|I6ykDFeu{fTV?5I)5)Q|cL(tCOh)m+8X*cGt)6b_|jUYZBV> z@;IrXFdqC4jGUtu1yFj{HQ6Hq`X_-pPP7GD`Xo*Q7xB1;@;UvQ{(QIDqv~m9kCBPa zuj#nBTuNLS9f371Xb{eY%${RaV!x|bVbrXm4Fad$#QqaGwOX8I%*n?5@Z#l37OkB} zK7(Rq@FUJ>pqT;ylNJs}E*y(7G>;iS{<<nxS7TC<sn-#vJWE!BlH;B=4F5A~6LVux z)JZjLxr#>a6E_~*BSWj!aWziLcc46K+t-bdw^k0uxw~efsga(Vx?hk1HEXbrD4Rc^ z@WO@NUbHJ4-NG{IiQPJff}oR&q8Q2L8S}#F4R*K`#Ts6KM(x}0S=R(@1J(p2G^GR2 zd)q`pZp)Rv^1kA$0H-|sCcKr84({F>dG){?BP>X{Rzb((LWjC4AWG}~)9|SR=rsmR zaS8wQYF-1!_D$y?=DSt?>Q4QpeV_M0h6krlO(8`02&XW&%*z{HX9S4zDX%*Ay~QYl znT91BnPDHmFei4I$zJ~bABn7EV|qjmWal_=e^Q4+556$h>S@}Q`zZ>P-GA+Lt!Ry# z7-W1*MnUL`4HoT0{Lq<-?XEf?rGyWX3o;ffRqOg!l~#&B8}`Pt2O^U6Bh}Le=RKH; z)ei6XJ*&=?>N~~%0-8nhci&-xv*RoAjfOLXhkYIZd>kx{g*!0@r#lDOGdQ{V1T8Na z&Wc2?##p+8GqM=Oz!)Z}2y|GY`25&}e1+dloT3-&i!fDTGj{J1=l4NAr5ZiczFA!z z41-b=LfETffRcu$1ElAB#KNFrM*XWGw2Nu=YP*O)!cG8C_8=C@|HFxiza-#^wRkWf z9NdYWcp?BT*~>!?q~252H(jX;>~1=y&>?9U?@f+5(er9Vtqqaa27}&0>`BJ)2f#W2 znpuM-<MoiT9G1;M;g{o|0LL0EM|FVgrq6#I_~<?@NICrb^(IQzPPOTizZ?dUp-6ej z&~smH`y=pYJLVR^E|@KFGuN@lU{{c$p1gBDIR((uVE3~A3e?w7_p%w^ipbGq_d+$h zx$l;#Ja8^JQIFRDEUs>-SkP&l)BDdQI6Y&hvIz#GW3oabRzhz;jIolY`yt5?mA}^N zX>aMjFw1Q%u+N^#g(X$pb@p4p>>i2=3P%kU^Tu~cdlCBoO$JJ@OyTuMy_xdU?59B+ zu=sfTm|MDeX(C$mEYwuHn{k`F2E<V-=y$%i(~8gc$?Ep8@%!=N;MLWzJy&sH2JW#a zLkmt*W(^2MAiN;(47jLQZ6)A&KAUY(&6nJ?JZrx0dt^k+FN<jUDQG33CTu3}2PM}= z8eSH1>I*OnAp4jSk&3idBT_R>)3UM#V0+vGN7bp5<)hn?nDyfeYyL6Lvp`$Ygee53 z*<<~Hb9lmLjgHA&1AQ`A#OTut7Cg%B5H~CbS~Yr(2InIpBSrA7WkP(d`jQ9tU5;6l zaE1%)A7eWSxGVv<kzrX0dy2R+J=H3bdnp*K8l5OQ$t8s?3ng)s@GXeKfo(kl@IgFJ zDTr&!^p=baF`KS{Vls<0E5P-k7V~nfv6D1D%f}ORGr>{1P5kv!u*1}38&vc3&_dU4 z?lH_W-ln_)Y>nd+ZnRu2{x&XHP_rZ1Gjs1DNS(xZQqV+OK{Vj_n4{5`1G#XODV5T* ze?>FdaTHQED-IJJhd0Xe6C<$#7F$1XyySTj=$w}+)K8AQEp6C^X#)SrLp`H$$G@UI z>-feF_nc*sP`s>+w<a~H2XLfCayM1uL~ga#??;cjE-Bbna-`2IQ%#6ZKh2o-)1O(_ zkc)pWeJwH}8(E5}vtPT_;I$lBvHz)-U0y1YfKoSw$vkD2v{(I1jY^~f#3@K*k*KQg zQpZ=obEYm*Yg)Hq$Z-HHITET;K$IKH>Xe<ZAZ;C&c%#?3S4Sxo9ch<`uki^)h5%sL z5$f+UrlNWV)NRc27BD3xaz7a~H~R2(md;OuH9`mu+xW%P%zZ~tFii4G^(QN4CF_I% zsQ0fmI@33Y*aHQ6z#{wr@xVV$?bT*bc!qGs&Pz>Ff5;-Q)Fh<|UQg+JxRneriIxft zgalsZ>EBJQG}v>}4hw(9-N%rZ*6zwR-1{}FmiYd@<RdSd@1Nai7tZR|DbAkPf|}W8 zECO7X?e4`SlQ>x8q71rmxGYO3=*G7e{n-wFL0heR;q4%D+N*j12q=-5HVEEvIv9UJ zZxK6$EZr|ame9gncarzE;q1X8iP}1PT+kSw?6IZtXx_4(>92mQz5srreRHPWNBklf zsQj(_dr|qxjL+|n;o#62eQaG-UmAor<e`UDQM;SL)=>mHkqRd<>w=j)YV!0@+PSi9 z@O?$zEEWcyZjdbi0orYFsuH%UH2saNxT+Q}k5yHW5BjU`5mpeMWZ_ro%yx*@VHsV{ z3{mU~0%A)x2GIvKqb-7n>dCqbof>koGA)7Lp8dU4d;D9lvPfmaa!RGf5%e@2-LE+& ze2*B3r2N5v@4UxC)Cs8=mWr^a>KsTR%)psiIjxjvq}mJsWpVe`O7FWF9D)v|uREjV zoRiRqFtMi~RNYCL9AsGa9B90Y;idxk`HP{K!~OaUm%flHxl;Qp0uGzfACpuPRupXY zKOE<d6*niaI_i5J4kmncTIi?&Ywh**N)3^WVb|~EL)J-BJ}&$~E+msB)BD0~+fX|u zA}Z8ZpJ~OGLR1EY4UYG3_v$0(FMU*|kGRY3oHTtm+ZAR=zcn15xs-ZWh*%irPC436 zZeD}SH}}*l=>xXknRr9V>Qd+E1X?Gpx-8Sw%GP{Se6m~fpKPni1QxutWe#!a#pwJc z2S00HkYXaZvx#>k1RzZ8iAbcP0NKb*q5pFqV(4CkJq;Q~?3ggH(*plg_p|~@XPfW6 z-fJ*zk!A8trt~kuczuEL{F6+FGf%%#Q07rM9!ITT1a)b$_2QBDxpU!td=zoKc)mz~ z-AgGiht?@tQy}g`Js$e_qx3_KpW!W`8II2*viZb5==cBvP`~oU-3xf@q<6o|!Nva? z)`ssp!*z#7vS#lS*$Wf(*Sf@aNL+<8iUxVOe<<9@rG?V=MErXCQtuYb2T?n{RjlT~ ziCVGn>iLI%jv+5u?dG+*Jg0BBycT%-hSoWEA==;ymVINW`FZqq{H26H`<On?X_mEa z9@i7J;?eJBvs{C|<^zCiZwv21BRVHT8kCT+#=$8L4%%_%;bIw!m|q!Sevq4TG##@j z`cpF{pl;?r$;ONCY;Q<prv|e$<Ve#Im)LpIk&b2d6@$RWP?pVO*lo_c2JA}isN8O` ztvoi<MBEJfm&R2hRYFFV9X-T-VU1pMPlwM%mnt$^((}M+7Z*_8t(TOgGQ#O2l5xz} zj@t0KOs7yQADVrv{xgY1$gxpp$AZX-JJxZFf=)@j=H^OeD#>S>qs)ftw|lklQo>_Q zq3S$3#~rTv%*rhX_Yir~lh2APd&4G=);iRg8efeYZh*s|rYWd+q^<vtDLsH$HVY`& zK3;r-7rh(?-WtIDM#}0*52yBUPN*t9qY(VdsFsi;H|49AQ5UQj-y6}Dgi4UFSQT$` zXywY<|K-Q&vM#8aLZ1;-SC@LM8+=T@m!1t0*%sU_aRr<<U1^)Rot>+B*B5(a%=aTl z{EH=d2eJ9I>9G-I(%vuHv10DkMBNXO1Mk>We$7sBEdp01-X_^A`HXpgE*kXTx<pI7 zC5HiKPW&Jj0p?2Nr(guk!BM3Pp`e7ANP)DWqALl*(%7{XW0W`hT;|2AShKfSU|J9V zv#r)%{MY=wOy0coA@1}j@Vb>g4mQ`b`}+EM`t9Qf>VA@S%4hC9)0pFI=he~Df<$^w zPKV5CV<(g4^^m`dB;qFywZ>s{iF`umJTve_L_B4$jf%@j_|*?6Hiz2(fS!M{-*QpE z{8H@E5(}@7juRKrkyU+U&ABcPuId5bOj`-{=PjyaOC#*_X|r{nO=fogLwurWoEehI z!^zg|bGptTg5yEgYwx_69079!0r41Xtbb;)v4v7)a>?}TECRQQlD*A8O~xl8)ku{} z*`bXhi%<iAeslzgtbId)eo+FV($pqBLyy(0qBTl6vE<EQ?VH!}6;d}3r|+X5Ap=G1 z^r^B0{RW1l6JcQ@|H;af%0(w7D`pB58l6<FJD+I$d#pIoTwP))(`gI;+I?5vR>&$+ zU=P!h3iOM8uagW<-BKJIlXS9s($QvddikAHwn)zX9QzAcP_(18kDpWlZzhx37}p3k z0qB#}lB)fPjpICktv{kq6A+Kq>-I+``aZ}pd_*L8k1rmX8+%10OdsLkO|l-*r-}Fl zR4qsgfYP@IbOl%Yh1SV3te_c>)M{kdOb7PJ6BxcEh{HXw$AHMDp@dRWjs?{r=-l*s z?&PH3U%>)!y3=*Tc4?(?*F!Bol&860Aq#NdDQ7Nt8p5}vPU7YNu;&CxYVp??@Oy$; zevpJD7s0Z-UL8*{hhq1@vbx7bzp(+&WVdH$zoMgk5CYcJhrklod<r_Xs8uEC7XFxB zjA3)gJ((<zDdBK5cpT;0Brf%{I~}Syw`um&ApVyXZJAZcO9;f(#Am3>s)so?E3t;d zuGB=ZS5+9a8+p2{FUY3Q={MEc3ERp)e^q!I9dB_rnon1yG5c!2CoLo#6bwY5j+};x zdbSJ44o>JyQ3UCiE|;m}O#)G)B_P#GH@pWnw@|C0T2;`}pjq+RE{lh-=WlqZSfC`l z4MqbHmzR~5%BZP_LXJGXEGIxl6G@C(a>A%-(u^QbJ^pf})4L<1B-Cnz1s~R#d4iVM z*CGrDAC0p4Ld!cJT8;##Z|PPEOOnTGlu57HS#PSMjxrA@2l@F~_(k@V#o;aEGFkSi zIT=woqEXC%XBbkfOz}-1RZHJsm}@UCWkmrP?90kuj!#DZ!C+EM=UZuuoh(u$Uc5`g z+p1WkhAF(jNWwzCY`JAoULJyvoG3Xj7Q;)oKwsYcwSKv3=eDGmQh-sXq}21>2x7ID z#t5yjP5^hRjMOyw_`|nBPNx3uBenL(7`O=S`!%m6r3zCpbNVZOWRj4c8;lO${xAyA zjBMIzEaf0ux{0{8<Plit6d=3H=a-Bg;g7%}<BZ@iL>-mP*}jxr*};%~xaUa2mX+^z z8$KxVecuQrOEjQuVkZXs)eLnqe{-tF&N|aXM<s(rfuzpLq)q&L>lj}(D%ard8I*uI z=C!LTMn@e^p5CU*635_JlOXH0NS>Z}LW2!_oIo1~Ek!~aBsmGTyO@|xD-QgaxJ#Q4 z+?5zWC&>J$NB?R{8u{1JS6!L7qNSpcxXtuk8QJbnd&zzn-$zL65+~m<5UZd*Ixo&h zLnt9k;yoQ2uxKI#y&yCU@oWKMCFny_q5?f8^dI((=L&XsMchO?dQ9Nx#8CPW<jK26 zQ`mC4x`c4acLqyc_w+<O25P|TM5Cf$kHhB8gP3sH#afK}&bP$xxEAZ<lHRWG8D;N| z>mj}0EU_Vs-(B|5tUgbNN%hrCTrnwOy<)jW9_Il`0oP6i3lh4cchkvDn#LekhjEnV z6VQEM9^?lf(kqY|YY_Q^Qu6&DN0+|S6K0JajC3nwPl_aILGtD7k8i;3F8A}yeWm;E z+VW!OqxQ+113sH5zb=UULTfQ)KFo-;dC{$`WKpjul;uB1tj_;-&Y?CPhGn>nDH95O zEizkG%yPIFyK;TS4b~-cv``e2MX&(rP_+MgebSU?vI;T{)%%0`%+`ZXWd)z$fJ=~V z8`#x?;ND3?S#8(W=rVvK`ByoniAlwfMy(l(`y_$`Lk({bnOC|0N&tOGz0P#9V^YeL zJo`0C#T|-ddit*O8DSZx=(H-{xuO~?_AES;4p~+W!N@6%PrtR1`Q+q)nRAjC$GD4v z;LvsmHn{*3j1kT&e-;+HmhXjL&*>68sma(<)=5+YGXWLMXc!>pSLV~<D#Gn9<)!yg z_d78v@Vj}3IR{_8;9hhq<0%<~_m=>Y@yORR8slLtwaC24kD40)#32XqGzrx+2kV3R z(y<DHDO^w^Jqn^(V;gI+fyEZrxXQK{+Dkr;Nw^XELSD%hq#vvcnhO;!-Ea=9RL)@l z?E8Fy1vOaSfd>HVHwFf|5IwYLQH(Y%yqTWdzhfClu#vccB+1KV31Zd<CXM<u4^hz6 z!U995`mfH@bUIn_Foj<4e70k9#BYJC5NjQUQWe*8D4LH%m1DI$z*sW$_g~Tzo-!90 zwPuc}B_}Lt`e&S*+XH<4QjPMa3s2+5l}lOpu$3?giv~c$oFfC**e!ZqBCBmd0V#{= zELecDNRt~ynMc!)G<~_G3&sU(1xbq)6y&DF?`=4~#F_C~b}<$&z&ow9P4S4>*YYsN z*OQ5V$XOVtB2H3rC#-V~GXmI?nOfhJY^X%TyGvmb)H<85(%X>UTJSgoRsZaDfSB>> zNhB6CqZ{Ba_va#{4iis*uS>}>l;pW2^0Hg$IwbKAdwdA?4(!;RXj0;<s#%OTu?+4c zQZIAni}4DDY^sua*yNs4*uzL_>Nw6!MsjGU6^SW(b9{MovnpvbyJ>ueZ0XXnd_S2p zpNbA{e^i%1Yk@v*5Knvf&=5<jn~1*_rIzeJP;J2I(fHUF)B4RF`v@O`T^SgJ?4AiU zTv8v>Hv>IzzSv*t2_j@qi#8Ds6=n_WP0|}6u@VJZMp})<{`Ts8=ms-49+0;&<D5Eo zeX8H|&`pAj#{CbyRr@s7r<20jS=i>C#?KvYT(_%@`oA-jL76{wFdMnl<(uxl+Z~?b z!>j;}+qSA(t$?Ri(MXQb)9M`6|DNSt%irgAaSNo@SfZV(PxgpqS=icPu6h8|ach@? zPe-SU8DrSbO=A72UA1UzFJQ*CSEG-`)&m8WQqgG?1nLHsQYu6(=CH6!%E(;+f&$(H zWfHqbqV86@WY8s55w=zBr(!M(9QZT2ly(DP1D)CW2ZmgwAY^rv5)ElL6BE6Nkdz|O z@^P){_YKk{``Y3<kexc9)58BMX&Z{&e-JD5{BEvt3FC2hFRHl9INOYH+5!H)D)}&O zR=-d&J$njPI6qmB^>K3km@>+zQ0a=Nu!+-j&{VBau-wU}%U0ruV$LS<wgWxK1v~_R z_J{UlS5=K$UDSxr04<jq@8OkwnX8yE;sjou)8@dW3CPfGYCB#OG9D?PKn+<%+^JVj zxYCpSt*^W7*~Fx?(og3+R3_rQw%gL|{!8<D@XU#D`+!k>2{PMZeNw-rC!d;P<I7G4 z&b8bTV=_njXvx2k!Rf$HIpLF0>39T43lEaZu$eI3z7b+Oaek}<J{|1vq7*e^8KM+* zVS!5AA0MCN!cLJ7ZPNI}5daTCpCq+oBRn=GSDK=_x{zzEoWwUw=ZyK$ympt3ETQXp zpnM(GX2|~7{>CMwO4r?K_E{#Ez13F52D!HV{BA+CuMqHYO&4tS61{Hz46Fs%F+^x+ z(oLsp^8whGBf*2qy18<x?~=HT*egb(C&^JMG0=~3*2k8phv(POCWp2hHH?*omDw<w z(73v-niWp9kQeV!=Z+fRX^rT5=|L(<ndm|Gq1s6DV){;zx$py2kv+IU!bltx!B!-J z3XscUgs6v|#rz|MHkEMd1wjD($VjA05|Q;+a$q|#eL`RfP0`uN7BNK`N)B&q8{<$} z;eD?pb-4w4$N_(lkrsTXlz7e}h4?_8&js+BPAs(_g}}V(z3Yi8w<IrfN<a-0(1?XQ zNA>BE=Sq&Mm?x#8Gs<oB+D#|Lkvb$Q&55Z;rZYI~3sWEj|Fy_J`p5u+PYZxLw;ozC z(JECjCRkZ<#Bq-39wI3wiIy4sbgR}cdXJhf@+*tfTc3}YmehEvTV>U~HLI=K&fP4Z zU#<<!55ZX{X<#afdJ%UI>1iHV;3b>9P&sY&c{*862wx_oHSREwCnOV<fVaYYj)*3Q zNh&2kB?=ddTMAB%O{V}fw3A#OJ804O7}3i1Q6o<?5}06mL>-2*DaxtfBq;IxLRPxA z=&I=Q=oxXPGZ~?L#wR_2>8keGAN1ugVK$4?ey$um^7sO)4(eWpI;Hv*QPTk|Iz!w3 z1dkw&fjc%UYkyX*_oE>2!WE3`xS;A8Vv$!#38cL87C_0DOlJUavVVe?{LjMGEViri zA7sM0NN3`f1Dt3om9=o}yG}Y%CU(E=G|s1z^Rr`WqNnu4QFnYA{X9myX#8?0rA8ao zyrA9UIH43_#A7p%W(?~#v~Kd#(tUlZH+&W#9B~o+lf+<V8tXEg*z^x6i{vk3ByZ#( z9x5v*hfP)OzV`t~vSws>f&63&m2EC;8hz~X#v$gzxDv)87IKrUF&0sd(C1pj7Xd1W zcLZ1k>xl$dc}sm*M!k?;{Bcr{s4~faVwIGV0%fR^O2OBkKP}{oar_KkT#X9Ggd~o0 zF^h}Ls;Fhp1=UW^sg7F#XT5%}@;a@%b&Z%iDOC%C0^EStTfol`<t#$a?;+~MM`~Wd z2kOFKJZz*|Qn$`gLpCcu9DrdKCl0Q8z46GgaE=SNZG+6AX|fv?P${Sb36KR@JCK^; zi4c$%$Q+*k2ap<M9v#p=(3=Ibwl&43Mq!8mwtp1%Y+qw>x1$<1Rqz<fZ_<+aBC*av zuS`cJ<2&GIs<QSvp{C95y@H+dI*wtt0X)j4tL`9^(_E}|UeIPXoFPueB#BZj7KKm_ z6=3h&u9Z7$yXySo$dJ2=8SR-p^jA{h?==Q|L$olcfS`jP*I^jp``=PRudlhQFc1KY z->{`%*25sQ;M3<m&~V?u@E_M6$$e`}e8`aAgiwI7&a+>c!l3z2Hiyd?;%8$A&7)|V z5t1O2s2|qTptRuAd;TgSv|zX&(pTRnv2-KfFm7QeZfD2L>OiVqU-~+EVG0lJokMBy zLd@T7tjjCNKjd@b?~8@80&cJ5>Z<=9WN0U6a(u-PUK-$4Db%v@ntHvWSsm~CKb&(< z3#tL~=ZgBSyd(Ls|DcnFYgG&FNSsW<hTL;l`k+CdaQEVX5wHwPfxY17Z2aqh@gVl# z&K^~xz99@zAi{Bptbpc$Kp~`X@?!t1SPYN2#2Uj>qd7vM9<Fd_QD^4MF1zAjd3wn` zKR@zXG)}0|52**Jx`ZoCcMp6_K&VMc_~-yJXemi;_!n92KiDP(<6<LWY65^5QO??b z^sysb;NV`#_CDdp&sF+$FpUMw0j&zrBUOhVm3g%=2x`Li=uc|G&ge^;!mj8^>cVX> zc<O!X{f6jS6@dXXLPw%#o~K$S;E8Je=`>vO@rqI6c0GC1|7?~-EsL&t+F_Ac^!@?R zB+CYnBHeh^$WO)#`J^S&i&QrhOOwh1J<yX>j_Uu_-5W6shTPv{ts21@rQF8O03r@J zvTlW_aKzm>C@q-K#?)I%lE!Skn3EJ0E%jbqT1SQp8pf0PE&sd*;)xZjF>ih(8q-?Q zu6J1Ob==le1AbQe7%vXcWeNS>N@oEluVB0I*V%&QZUHWaPVdd#yPgtWUXL5Dw%3=3 zQ}VF{En6n=S67w*hvk;(V_fU2)D*$yv+AH&ou_Q@Pt4p+HR4cSSxv^Cly$G|7(Ja1 zx0^1N7R$3*7LF)Xcr0apvKEv=W!vTsy(&A?sJgz!Dpi+_0_M_Ti)Jn61qT4nR$sfL z_;%fH^uT^-8*YIe(l*mxXc6v*g@yy@EyO`6F^uq%%~bu70`yo1T|E&u!O!YB5^ZK3 zHB3Vxh;y`-8r8oL@dEvnEIp;Z?$V=SMsu?#7SdbGdc}MJCM3ag^wdo@96`s=?Eu3V z2AoO9a$AnDAUy)-I}8^iIxzs8h&_@N63q)_0OEil5=3Ae+<>1p5@Mhfv5Yp<K-zni zY8L*%_=BM2HG)D&P4kFMDgotRSBs=*If)omtaA6)s+_EkYVR8S9dFvCvdmjtk7q^u z8b4cVIQ(08oUrY$+jb+x_@t~B!1=BKPRftBPuvG(q(cnh0p7Js%p?FWE5M_hZxk}n zO#r8nH@V!B$#CgTzpAe-wQw>pQ5UKBk-C_b_&h?-CzQH<+^Jd>`S=VC{Z<AarOK3J z>G1G$j{6`1*!8OFP!!~GB*nWJAHdaIuKLZS_9|r{)xVRivoh3C<lJn%r}zuuaC*aY zNX_#pYLDFY@fAzKL<rDEH$XgXL-R$%jEHQU2X!vjJq!3#z+9N(&aE~2fORQSYiZYl z$u9OyIqWI&mPlZcguejySUt(4PqwKzA-RrTRrrY$c#zs4TZ;FGRQ`8Uhx1jQQEQzv z`~BthW=jn@2kiIl(?N;HyG_iB_gT))DmE&!C_{AXD9U3k1P@>au2hK+v2t0T;88Wd zVQKoWs!yd<zqCP{8Rh8kOoVg9g|3X-)i|n=npY08;U$-=W7=f{wv?(X?1oLRjoOvS zTC!50yPVv_-YQ$+@ULHAq;fjug8q^w)yN|!yd<Y6(;ZGFLj0s8KXtkvxZ!j*K|dPb z&(2+BEZ$$iaufhc=}u^OKeuz>RL1`7;->ootbKZz!EIs&p*W5aSCJQ*<r!(%kUY&P zSMRpX(iqx9IX*?qv8zpt>U!;SU5kVu*!NvIVb0Saf*{?aXfYw3AJ+Y#w<V%P5y2E) zyq9qIQm3+R4)Db@kp$o2VG)<#wwAMk#P)djv|fIZYA0a-ANeY^cWOEz3|N}8mbMF! zAA_DVBkMV|L;us@T@dKP^xcK;V6V5xMWIgB6puubJ6rLy5T_3Lw*zR1buz!a=dLUH z!k{!rk!aTCk!S9Bn9VT@cNIsa^fWj)K?Hj2yAO}Tsl0Qd`d>=O|B@9hY9SFIWhrpe z2^$H`$1cD%5w}Yfmp#9!Uh&G<<3c{eU~)40`e7|x@B0|C*-v{uuf0F*O`LvT`T6b7 z{A-MWFJq(F!NTO{kaH)1$M3DS$H@#@*ZmiMP;i@kmR3`HfI_)un=JJTB^8n`Yx?Bn zZ2O}tZu3M4_UJA>*kbDF;ghZBm|0=45saTZ5(^NxgA|9PmK18p1P01Rh!~KTTmu(< zaY}cLhaH`;2yE#EE9WMr8sI3ysOa#ndRZn0T?TX03DPumw2ArB`_!q~(o}&cec)E| z-hr=^I>I10+Ts;i5(Yc_BbX~=uIHLa=7Pg_wGcpfjG$9Zy`A@iT2mOdMYJ(x*9sp# zJ_cyet{xvb@sJ;t>_I9$mvf~E+Av<mc8O+YEga@EHr(dWs9fO~f$~?i#hnUB<YtNk z9mhMH44A-^@=im($S-<pBI+L0kEbuD7M`BoF<uSmx~wr-cO@QA->?ev^MLe<#lt4J zI<<RyXr9vN9nGPgeRtxYR#ZM1%>1uZtpto^T3GWJ1e7gHBsQ!oEZ}Y#ySMIFWUzQj z@~UKRjMvC#GxCKz-6t>(-F7^AMP@29SFIFG+_z`L*&oJN>S?F2dfHayG^Wu-Ge?iR zjX{3;)-6I#3zWU~(-ArdU_JC(5<Y$I0TVvW6<84_z3&wvD!c(z;z=FSI`l
nj& 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 diff --git a/extra_script.py b/extra_script.py index 7713451..b8b5c97 100644 --- a/extra_script.py +++ b/extra_script.py @@ -18,6 +18,8 @@ OWN_FILE="extra_script.py" GEN_DIR='lib/generated' CFG_FILE='web/config.json' XDR_FILE='web/xdrconfig.json' +INDEXJS="index.js" +INDEXCSS="index.css" CFG_INCLUDE='GwConfigDefinitions.h' CFG_INCLUDE_IMPL='GwConfigDefImpl.h' XDR_INCLUDE='GwXdrTypeMappings.h' @@ -66,6 +68,7 @@ def isCurrent(infile,outfile): def compressFile(inFile,outfile): if isCurrent(inFile,outfile): return + print("compressing %s"%inFile) with open(inFile, 'rb') as f_in: with gzip.open(outfile, 'wb') as f_out: shutil.copyfileobj(f_in, f_out) @@ -372,6 +375,16 @@ def getLibs(): rt.append(e) return rt +def joinFiles(target,pattern,dirlist): + with gzip.open(target,"wb") as oh: + for dir in dirlist: + fn=os.path.join(dir,pattern) + if os.path.exists(fn): + print("adding %s to %s"%(fn,target)) + with open(fn,"rb") as rh: + shutil.copyfileobj(rh,oh) + + OWNLIBS=getLibs()+["FS","WiFi"] GLOBAL_INCLUDES=[] @@ -440,6 +453,8 @@ def prebuild(env): compressFile(mergedConfig,mergedConfig+".gz") generateCfg(mergedConfig,os.path.join(outPath(),CFG_INCLUDE),False) generateCfg(mergedConfig,os.path.join(outPath(),CFG_INCLUDE_IMPL),True) + joinFiles(os.path.join(outPath(),INDEXJS+".gz"),INDEXJS,["web"]+userTaskDirs) + joinFiles(os.path.join(outPath(),INDEXCSS+".gz"),INDEXCSS,["web"]+userTaskDirs) embedded=getEmbeddedFiles(env) filedefs=[] for ef in embedded: @@ -453,7 +468,6 @@ def prebuild(env): filedefs.append((pureName,usname,ct)) inFile=os.path.join(basePath(),"web",pureName) if os.path.exists(inFile): - print("compressing %s"%inFile) compressFile(inFile,ef) else: print("#WARNING: infile %s for %s not found"%(inFile,ef)) diff --git a/web/config.json b/web/config.json index ca155ec..5c21eb5 100644 --- a/web/config.json +++ b/web/config.json @@ -351,7 +351,7 @@ "check": "checkMinMax", "min": -1, "max": 256, - "description": "the temp instance of PGN 130312 used for water temperature, use -1 for none, 256 for any", + "description": "the temp instance of PGN 130312 used for water temperature (0...255), use -1 for none, 256 for any", "default": "256", "category":"converter" }, diff --git a/web/index.css b/web/index.css index c3ea7ee..aada09f 100644 --- a/web/index.css +++ b/web/index.css @@ -340,4 +340,7 @@ body { } .error{ color: red; +} +.changed input.error{ + color: red; } \ No newline at end of file diff --git a/web/index.js b/web/index.js index 032b087..d61556b 100644 --- a/web/index.js +++ b/web/index.js @@ -1,1933 +1,1988 @@ -let self = this; -let lastUpdate = (new Date()).getTime(); -let reloadConfig = false; -let needAdminPass=true; -let lastSalt=""; -let channelList={}; -let minUser=200; -function addEl(type, clazz, parent, text) { - let el = document.createElement(type); - if (clazz) { - if (!(clazz instanceof Array)) { - clazz = clazz.split(/ */); - } - clazz.forEach(function (ce) { - el.classList.add(ce); - }); - } - if (text) el.textContent = text; - if (parent) parent.appendChild(el); - return el; -} -function forEl(query, callback,base) { - if (! base) base=document; - let all = base.querySelectorAll(query); - for (let i = 0; i < all.length; i++) { - callback(all[i]); - } -} -function closestParent(element,clazz){ - while (true){ - let parent=element.parentElement; - if (! parent) return; - if (parent.classList.contains(clazz)) return parent; - element=parent; - } -} -function alertRestart() { - reloadConfig = true; - alert("Board reset triggered, reconnect WLAN if necessary"); -} -function getJson(url) { - return fetch(url) - .then(function (r) { return r.json() }); -} -function getText(url){ - return fetch(url) - .then(function (r) { return r.text() }); -} -function reset() { - ensurePass() - .then(function (hash) { - fetch('/api/reset?_hash='+encodeURIComponent(hash)); - alertRestart(); - }) - .catch(function (e) { }); -} -function update() { - let now = (new Date()).getTime(); - let ce = document.getElementById('connected'); - if (ce) { - if ((lastUpdate + 3000) > now) { - ce.classList.add('ok'); - } - else { - ce.classList.remove('ok'); - } - } - getJson('/api/status') - .then(function (jsonData) { - let statusPage=document.getElementById('statusPageContent'); - let even=true; //first counter - for (let k in jsonData) { - if (k == "salt"){ - lastSalt=jsonData[k]; - continue; - } - if (k == "minUser"){ - minUser=parseInt(jsonData[k]); - continue; - } - if (! statusPage) continue; - if (typeof (jsonData[k]) === 'object') { - if (k.indexOf('count') == 0) { - createCounterDisplay(statusPage, k.replace("count", "").replace(/in$/," in").replace(/out$/," out"), k, even); - even = !even; - for (let sk in jsonData[k]) { - let key = k + "." + sk; - if (typeof (jsonData[k][sk]) === 'object') { - //msg details - updateMsgDetails(key, jsonData[k][sk]); - } - else { - let el = document.getElementById(key); - if (el) el.textContent = jsonData[k][sk]; - } - } - } - if (k.indexOf("ch")==0){ - //channel def - let name=k.substring(2); - channelList[name]=jsonData[k]; - } - } - else { - let el = document.getElementById(k); - if (el) el.textContent = jsonData[k]; - forEl('.status-'+k,function(el){ - el.textContent=jsonData[k]; - }); - } +(function () { + let self = this; + let lastUpdate = (new Date()).getTime(); + let reloadConfig = false; + let needAdminPass = true; + let lastSalt = ""; + let channelList = {}; + let minUser = 200; + let listeners = []; + function addEl(type, clazz, parent, text) { + let el = document.createElement(type); + if (clazz) { + if (!(clazz instanceof Array)) { + clazz = clazz.split(/ */); } - lastUpdate = (new Date()).getTime(); - if (reloadConfig) { - reloadConfig = false; - resetForm(); - } - }) -} -function resetForm(ev) { - getJson("/api/config") - .then(function (jsonData) { - for (let k in jsonData) { - if (k == "useAdminPass"){ - needAdminPass=jsonData[k] != 'false'; - } - let el = document.querySelector("[name='" + k + "']"); - if (el) { - let v = jsonData[k]; - let def=getConfigDefition(k); - if (def.check == 'checkMinMax'){ - //simple migration if the current value is outside the range - //we even "hide" this from the user - v=parseFloat(v); - if (! isNaN(v)){ - if (def.min !== undefined){ - if (v < parseFloat(def.min)) v=parseFloat(def.min); - } - if (def.max !== undefined){ - if (v > parseFloat(def.max)) v=parseFloat(def.max); - } - } - } - if (el.tagName === 'SELECT') { - setSelect(el,v); - } - else{ - el.value = v; - } - el.setAttribute('data-loaded', v); - let changeEvent = new Event('change'); - el.dispatchEvent(changeEvent); - } - } - let name=jsonData.systemName; - if (name){ - let el=document.getElementById('headline'); - if (el) el.textContent=name; - document.title=name; - } - }); -} -function checkMinMax(v,allValues,def){ - let parsed=parseFloat(v); - if (isNaN(parsed)) return "must be a number"; - if (def.min !== undefined){ - if (parsed < parseFloat(def.min)) return "must be >= "+def.min; - } - if (def.max !== undefined){ - if (parsed > parseFloat(def.max)) return "must be <= "+def.max; - } -} - -function checkSystemName(v) { - //2...32 characters for ssid - let allowed = v.replace(/[^a-zA-Z0-9]*/g, ''); - if (allowed != v) return "contains invalid characters, only a-z, A-Z, 0-9"; - if (v.length < 2 || v.length > 32) return "invalid length (2...32)"; -} -function checkApPass(v) { - //min 8 characters - if (v.length < 8) { - return "password must be at least 8 characters"; - } -} -function checkAdminPass(v){ - return checkApPass(v); -} - -function checkApIp(v,allValues){ - if (! v) return "cannot be empty"; - let err1="must be in the form 192.168.x.x"; - if (! v.match(/[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/))return err1; - let parts=v.split("."); - if (parts.length != 4) return err1; - for (let idx=0;idx < 4;idx++){ - let iv=parseInt(parts[idx]); - if (iv < 0 || iv > 255) return err1; - } -} -function checkNetMask(v,allValues){ - return checkApIp(v,allValues); -} - -function checkIpAddress(v,allValues,def){ - if (allValues.tclEnabled != "true") return; - if (! v) return "cannot be empty"; - if (! v.match(/[0-9]*\.[0-9]*\.[0-9]*\.[0-9]*/) - && ! v.match(/.*\.local/)) - return "must be either in the form 192.168.1.1 or xxx.local"; -} - -function checkXDR(v,allValues){ - if (! v) return; - let parts=v.split(','); - if (parseInt(parts[1]) == 0) return; - if (parseInt(parts[1]) != 0 && ! parts[6]) return "missing transducer name"; - for (let k in allValues){ - if (! k.match(/^XDR/)) continue; - let cmp=allValues[k]; - if (cmp == v){ - return "same mapping already defined in "+k; - } - let cmpParts=cmp.split(','); - if (parseInt(cmpParts[1]) != 0 && parts[6] == cmpParts[6]){ - return "transducer "+parts[6]+" already defined in "+k; - } - //check similar mappings - if (parts[0]==cmpParts[0] && parts[2] == cmpParts[2] && parts[3] == cmpParts[3]){ - if (parts[4] == cmpParts[4] && parts[5] == cmpParts[5]){ - return "mapping for the same entity already defined in "+k; - } - if ((parseInt(parts[4]) == 0 && parseInt(cmpParts[4]) == 1)|| - (parseInt(parts[4]) == 1 && parseInt(cmpParts[4]) == 0) - ){ - //ignore and single for the same mapping - return "mapping for the same entity already defined in "+k; - } - } - } -} -function getAllConfigs(omitPass) { - let values = document.querySelectorAll('.configForm select , .configForm input'); - let allValues = {}; - for (let i = 0; i < values.length; i++) { - let v = values[i]; - let name = v.getAttribute('name'); - if (!name) continue; - if (name.indexOf("_") >= 0) continue; - if (v.getAttribute('disabled')) continue; - let def = getConfigDefition(name); - if (def.type === 'password' && ( v.value == '' || omitPass)) { - continue; - } - let check = v.getAttribute('data-check'); - if (check) { - if (typeof (self[check]) === 'function') { - let res = self[check](v.value, allValues, getConfigDefition(name)); - if (res) { - let value = v.value; - if (v.type === 'password') value = "******"; - alert("invalid config for " + v.getAttribute('name') + "(" + value + "):\n" + res); - return; - } - } - } - allValues[name] = v.value; - } - return allValues; -} -function changeConfig() { - ensurePass() - .then(function (pass) { - let newAdminPass; - let url = "/api/setConfig" - let body="_hash="+encodeURIComponent(pass)+"&"; - let allValues=getAllConfigs(); - if (!allValues) return; - for (let name in allValues){ - if (name == 'adminPassword'){ - newAdminPass=allValues[name]; - } - body += encodeURIComponent(name) + "=" + encodeURIComponent(allValues[name]) + "&"; - } - fetch(url,{ - method:'POST', - headers:{ - 'Content-Type': 'application/octet-stream' //we must lie here - }, - body: body - }) - .then((rs)=>rs.json()) - .then(function (status) { - if (status.status == 'OK') { - if (newAdminPass !== undefined) { - forEl('#adminPassInput', function (el) { - el.valu = newAdminPass; - }); - saveAdminPass(newAdminPass,true); - } - alertRestart(); - } - else { - alert("unable to set config: " + status.status); - } - }) - }) - .catch(function (e) { alert(e); }) -} -function factoryReset() { - ensurePass() - .then(function (hash) { - if (!confirm("Really delete all configuration?\n" + - "This will reset all your Wifi settings and disconnect you.")) { - return; - } - getJson("/api/resetConfig?_hash="+encodeURIComponent(hash)) - .then(function (status) { - alertRestart(); - }) - }) - .catch(function (e) { }); -} -function createCounterDisplay(parent,label,key,isEven){ - if (parent.querySelector("#"+key)){ - return; - } - let clazz="row icon-row counter-row"; - if (isEven) clazz+=" even"; - let row=addEl('div',clazz,parent); - row.setAttribute("id",key); - let icon=addEl('span','icon icon-more',row); - addEl('span','label',row,label); - let value=addEl('span','value',row,'---'); - value.setAttribute('id',key+".sumOk"); - let display=addEl('div',clazz+" msgDetails hidden",parent); - display.setAttribute('id',key+".ok"); - row.addEventListener('click',function(ev){ - let rs=display.classList.toggle('hidden'); - if (rs){ - icon.classList.add('icon-more'); - icon.classList.remove('icon-less'); - } - else{ - icon.classList.remove('icon-more'); - icon.classList.add('icon-less'); - } - }); -} -function validKey(key){ - if (! key) return; - return key.replace(/[^a-z_:A-Z0-9-]/g,''); -} -function updateMsgDetails(key, details) { - forEl('.msgDetails', function (frame) { - if (frame.getAttribute('id') !== key) return; - for (let k in details) { - k=validKey(k); - let el = frame.querySelector("[data-id=\"" + k + "\"] "); - if (!el) { - el = addEl('div', 'row', frame); - let cv = addEl('span', 'label', el, k); - cv = addEl('span', 'value', el, details[k]); - cv.setAttribute('data-id', k); - } - else { - el.textContent = details[k]; - } - } - forEl('.value',function(el){ - let k=el.getAttribute('data-id'); - if (k && ! details[k]){ - el.parentElement.remove(); - } - },frame); - }); -} - -function showOverlay(text, isHtml,buttons) { - let el = document.getElementById('overlayContent'); - if (text instanceof Object){ - el.textContent=''; - el.appendChild(text); - } - else { - if (isHtml) { - el.innerHTML = text; - el.classList.remove("text"); - } - else { - el.textContent = text; - el.classList.add("text"); - } - } - buttons=(buttons?buttons:[]).concat([{label:"Close",click:hideOverlay}]); - let container = document.getElementById('overlayContainer'); - let btframe=container.querySelector('.overlayButtons'); - btframe.textContent=''; - buttons.forEach((btconfig)=>{ - let bt=addEl('button','',btframe,btconfig.label); - bt.addEventListener("click",btconfig.click); - }); - container.classList.remove('hidden'); -} -function hideOverlay() { - let container = document.getElementById('overlayContainer'); - container.classList.add('hidden'); - let el = document.getElementById('overlayContent'); - el.textContent=''; -} -function checkChange(el, row,name) { - let loaded = el.getAttribute('data-loaded'); - if (loaded !== undefined) { - if (loaded != el.value) { - row.classList.add('changed'); - } - else { - row.classList.remove("changed"); - } - } - let dependend=conditionRelations[name]; - if (dependend){ - for (let el in dependend){ - checkCondition(dependend[el]); - } - } -} -let configDefinitions={}; -let xdrConfig={}; -//a map between the name of a config item and a list of dependend items -let conditionRelations={}; -function getConfigDefition(name){ - if (! name) return {}; - let def; - for (let k in configDefinitions){ - if (configDefinitions[k].name === name){ - def=configDefinitions[k]; - break; - } - } - if (! def) return {}; - return def; -} -function getConditions(name){ - let def=getConfigDefition(name); - if (! def) return; - let condition=def.condition; - if (! condition) return; - if (! (condition instanceof Array)) condition=[condition]; - return condition; -} -function checkCondition(element){ - let name=element.getAttribute('name'); - let condition=getConditions(name); - if (! condition) return; - let visible=false; - if (! condition instanceof Array) condition=[condition]; - condition.forEach(function(cel){ - let lvis=true; - for (let k in cel){ - let item=document.querySelector('[name='+k+']'); - if (item){ - let compare=cel[k]; - if (compare instanceof Array){ - if (compare.indexOf(item.value) < 0) lvis=false; - } - else{ - if (item.value != cel[k]) lvis=false; - } - } - } - if (lvis) visible=true; - }); - let row=closestParent(element,'row'); - if (!row) return; - if (visible) row.classList.remove('hidden'); - else row.classList.add('hidden'); -} -let caliv=0; -function createCalSetInput(configItem,frame,clazz){ - let el = addEl('input',clazz,frame); - let cb = addEl('button','',frame,'C'); - //el.disabled=true; - cb.addEventListener('click',(ev)=>{ - let cs=document.getElementById("calset").cloneNode(true); - cs.classList.remove("hidden"); - cs.querySelector(".heading").textContent=configItem.label||configItem.name; - let vel=cs.querySelector(".val"); - if (caliv != 0) window.clearInterval(caliv); - caliv=window.setInterval(()=>{ - if (document.body.contains(cs)){ - fetch("/api/calibrate?name="+encodeURIComponent(configItem.name)) - .then((r)=>r.text()) - .then((txt)=>{ - if (txt != vel.textContent){ - vel.textContent=txt; - } - }) - .catch((e)=>{ - alert(e); - hideOverlay(); - window.clearInterval(caliv); - }) - } - else{ - window.clearInterval(caliv); - } - },200); - showOverlay(cs,false,[{label:'Set',click:()=>{ - el.value=vel.textContent; - let cev=new Event('change'); - el.dispatchEvent(cev); - }}]); - }) - el.setAttribute('name', configItem.name) - return el; -} -function createCalValInput(configItem,frame,clazz){ - let el = addEl('input',clazz,frame); - let cb = addEl('button','',frame,'C'); - //el.disabled=true; - cb.addEventListener('click',(ev)=>{ - const sv=function(val,cfg){ - if (configItem.eval){ - let v=parseFloat(val); - let c=parseFloat(cfg); - return(eval(configItem.eval)); - } - return v; - }; - let cs=document.getElementById("calval").cloneNode(true); - cs.classList.remove("hidden"); - cs.querySelector(".heading").textContent=configItem.label||configItem.name; - let vel=cs.querySelector(".val"); - let vinp=cs.querySelector("input"); - vinp.value=el.value; - if (caliv != 0) window.clearInterval(caliv); - caliv=window.setInterval(()=>{ - if (document.body.contains(cs)){ - fetch("/api/calibrate?name="+encodeURIComponent(configItem.name)) - .then((r)=>r.text()) - .then((txt)=>{ - txt=sv(txt,vinp.value); - if (txt != vel.textContent){ - vel.textContent=txt; - } - }) - .catch((e)=>{ - alert(e); - hideOverlay(); - window.clearInterval(caliv); - }) - } - else{ - window.clearInterval(caliv); - } - },200); - showOverlay(cs,false,[{label:'Set',click:()=>{ - el.value=vinp.value; - let cev=new Event('change'); - el.dispatchEvent(cev); - }}]); - }) - el.setAttribute('name', configItem.name) - return el; -} -function createInput(configItem, frame,clazz) { - let el; - if (configItem.type === 'boolean' || configItem.type === 'list' || configItem.type == 'boatData') { - el=addEl('select',clazz,frame); - if (configItem.readOnly) el.setAttribute('disabled',true); - el.setAttribute('name', configItem.name) - let slist = []; - if (configItem.list) { - configItem.list.forEach(function (v) { - if (v instanceof Object){ - slist.push({l:v.l,v:v.v}); - } - else{ - slist.push({ l: v, v: v }); - } - }) - } - else if (configItem.type != 'boatData') { - slist.push({ l: 'on', v: 'true' }) - slist.push({ l: 'off', v: 'false' }) - } - slist.forEach(function (sitem) { - let sitemEl = addEl('option','',el,sitem.l); - sitemEl.setAttribute('value', sitem.v); - }) - if (configItem.type == 'boatData'){ - el.classList.add('boatDataSelect'); + clazz.forEach(function (ce) { + el.classList.add(ce); + }); } + if (text) el.textContent = text; + if (parent) parent.appendChild(el); return el; } - if (configItem.type === 'filter') { - return createFilterInput(configItem,frame,clazz); + function forEl(query, callback, base) { + if (!base) base = document; + let all = base.querySelectorAll(query); + for (let i = 0; i < all.length; i++) { + callback(all[i]); + } } - if (configItem.type === 'xdr'){ - return createXdrInput(configItem,frame,clazz); + function closestParent(element, clazz) { + while (true) { + let parent = element.parentElement; + if (!parent) return; + if (parent.classList.contains(clazz)) return parent; + element = parent; + } } - if (configItem.type === "calset"){ - return createCalSetInput(configItem,frame,clazz); + function alertRestart() { + reloadConfig = true; + alert("Board reset triggered, reconnect WLAN if necessary"); } - if (configItem.type === "calval"){ - return createCalValInput(configItem,frame,clazz); + function getJson(url) { + return fetch(url) + .then(function (r) { return r.json() }); } - el = addEl('input',clazz,frame); - if (configItem.readOnly) el.setAttribute('disabled',true); - el.setAttribute('name', configItem.name) - if (configItem.type === 'password') { - el.setAttribute('type', 'password'); - let vis = addEl('span', 'icon-eye icon', frame); - vis.addEventListener('click', function (v) { - if (vis.classList.toggle('active')) { - el.setAttribute('type', 'text'); + function getText(url) { + return fetch(url) + .then(function (r) { return r.text() }); + } + function reset() { + ensurePass() + .then(function (hash) { + fetch('/api/reset?_hash=' + encodeURIComponent(hash)); + alertRestart(); + }) + .catch(function (e) { }); + } + function update() { + let now = (new Date()).getTime(); + let ce = document.getElementById('connected'); + if (ce) { + if ((lastUpdate + 3000) > now) { + ce.classList.add('ok'); } else { - el.setAttribute('type', 'password'); + ce.classList.remove('ok'); } - }); - } - else if (configItem.type === 'number') { - el.setAttribute('type', 'number'); - } - else { - el.setAttribute('type', 'text'); - } - return el; -} - -function setSelect(item,value){ - if (!item) return; - item.value=value; - if (item.value !== value){ - //missing option with his value - let o=addEl('option',undefined,item,value); - o.setAttribute('value',value); - item.value=value; - } -} - -function updateSelectList(item,slist,opt_keepValue){ - let idx=0; - let value; - if (opt_keepValue) value=item.value; - item.innerHTML=''; - slist.forEach(function (sitem) { - let sitemEl = addEl('option','',item,sitem.l); - sitemEl.setAttribute('value', sitem.v !== undefined?sitem.v:idx); - idx++; - }) - if (value !== undefined){ - setSelect(item,value); - } -} -function getXdrCategories(){ - let rt=[]; - for (let c in xdrConfig){ - if (xdrConfig[c].enabled !== false){ - rt.push({l:c,v:xdrConfig[c].id}); } - } - return rt; -} -function getXdrCategoryName(cid){ - category=parseInt(cid); - for (let c in xdrConfig){ - let base=xdrConfig[c]; - if (parseInt(base.id) == category){ - return c; - } - } -} -function getXdrCategory(cid){ - category=parseInt(cid); - for (let c in xdrConfig){ - let base=xdrConfig[c]; - if (parseInt(base.id) == category){ - return base; - } - } - return {}; -} -function getXdrSelectors(category){ - let base=getXdrCategory(category); - return base.selector || []; -} -function getXdrFields(category){ - let base=getXdrCategory(category); - if (!base.fields) return []; - let rt=[]; - base.fields.forEach(function(f){ - if (f.t === undefined) return; - if (parseInt(f.t) == 99) return; //unknown type - rt.push(f); - }); - return rt; -} - -function createXdrLine(parent,label){ - let d=addEl('div','xdrline',parent); - addEl('span','xdrlabel',d,label); - return d; -} -function showHideXdr(el,show,useParent){ - if (useParent) el=el.parentElement; - if (show) el.classList.remove('xdrunused'); - else el.classList.add('xdrunused'); -} - -function createXdrInput(configItem,frame){ - let configCategory=configItem.category; - let el = addEl('div','xdrinput',frame); - let d=createXdrLine(el,'Direction'); - let direction=createInput({ - type:'list', - name: configItem.name+"_dir", - list: [ - //GwXDRMappingDef::Direction - {l:'off',v:0}, - {l:'bidir',v:1}, - {l:'to2K',v:2}, - {l:'from2K',v:3} - ], - readOnly: configItem.readOnly - },d,'xdrdir'); - d=createXdrLine(el,'Category'); - let category=createInput({ - type: 'list', - name: configItem.name+"_cat", - list:getXdrCategories(), - readOnly: configItem.readOnly - },d,'xdrcat'); - d=createXdrLine(el,'Source'); - let selector=createInput({ - type: 'list', - name: configItem.name+"_sel", - list:[], - readOnly: configItem.readOnly - },d,'xdrsel'); - d=createXdrLine(el,'Field'); - let field=createInput({ - type:'list', - name: configItem.name+'_field', - list: [], - readOnly: configItem.readOnly - },d,'xdrfield'); - d=createXdrLine(el,'Instance'); - let imode=createInput({ - type:'list', - name: configItem.name+"_imode", - list:[ - //GwXDRMappingDef::InstanceMode - {l:'single',v:0}, - {l:'ignore',v:1}, - {l:'auto',v:2} - ], - readOnly: configItem.readOnly - },d,'xdrimode'); - let instance=createInput({ - type:'number', - name: configItem.name+"_instance", - readOnly: configItem.readOnly - },d,'xdrinstance'); - d=createXdrLine(el,'Transducer'); - let xdrName=createInput({ - type:'text', - name: configItem.name+"_xdr", - readOnly: configItem.readOnly - },d,'xdrname'); - d=createXdrLine(el,'Example'); - let example=addEl('div','xdrexample',d,''); - let data = addEl('input','xdrvalue',el); - data.setAttribute('type', 'hidden'); - data.setAttribute('name', configItem.name); - if (configItem.readOnly) data.setAttribute('disabled',true); - let changeFunction = function () { - let parts=data.value.split(','); - direction.value=parts[1] || 0; - category.value=parts[0] || 0; - let selectors=getXdrSelectors(category.value); - updateSelectList(selector,selectors); - showHideXdr(selector,selectors.length>0); - let fields=getXdrFields(category.value); - updateSelectList(field,fields); - showHideXdr(field,fields.length>0); - selector.value=parts[2]||0; - field.value=parts[3]||0; - imode.value=parts[4]||0; - instance.value=parts[5]||0; - showHideXdr(instance,imode.value == 0); - xdrName.value=parts[6]||''; - let used=isXdrUsed(data); - let modified=data.value != data.getAttribute('data-loaded'); - forEl('[data-category='+configCategory+']',function(el){ - if (used) { - el.classList.add('xdrcused'); - el.classList.remove('xdrcunused'); - forEl('.categoryAdd',function(add){ - add.textContent=xdrName.value; - },el); - } - else { - el.classList.remove('xdrcused'); - el.classList.add('xdrcunused'); - forEl('.categoryAdd',function(add){ - add.textContent=''; - },el); - } - if (modified){ - el.classList.add('changed'); - } - else{ - el.classList.remove('changed'); - } - }); - if (used){ - getText('/api/xdrExample?mapping='+encodeURIComponent(data.value)+'&value=2.1') - .then(function(txt){ - example.textContent=txt; - }) - } - else{ - example.textContent=''; - } - } - let updateFunction = function (evt) { - if (evt.target == category){ - selector.value=0; - field.value=0; - instance.value=0; - } - let txt=category.value+","+direction.value+","+ - selector.value+","+field.value+","+imode.value; - let instanceValue=parseInt(instance.value||0); - if (isNaN(instanceValue)) instanceValue=0; - if (instanceValue<0) instanceValue=0; - if (instanceValue>255) instanceValue=255; - txt+=","+instanceValue; - let xdr=xdrName.value.replace(/[^a-zA-Z0-9]/g,''); - xdr=xdr.substr(0,12); - txt+=","+xdr; - data.value=txt; - let ev=new Event('change'); - data.dispatchEvent(ev); - } - category.addEventListener('change',updateFunction); - direction.addEventListener('change',updateFunction); - selector.addEventListener('change',updateFunction); - field.addEventListener('change',updateFunction); - imode.addEventListener('change',updateFunction); - instance.addEventListener('change',updateFunction); - xdrName.addEventListener('change',updateFunction); - data.addEventListener('change',changeFunction); - return data; -} - -function isXdrUsed(element){ - let parts=element.value.split(','); - if (! parts[1]) return false; - if (! parseInt(parts[1])) return false; - if (! parts[6]) return false; - return true; -} - -function createFilterInput(configItem, frame) { - let el = addEl('div','filter',frame); - let ais = createInput({ - type: 'list', - name: configItem.name + "_ais", - list: ['aison', 'aisoff'], - readOnly: configItem.readOnly - }, el); - let mode = createInput({ - type: 'list', - name: configItem.name + "_mode", - list: ['whitelist', 'blacklist'], - readOnly: configItem.readOnly - }, el); - let sentences = createInput({ - type: 'text', - name: configItem.name + "_sentences", - readOnly: configItem.readOnly - }, el); - let data = addEl('input',undefined,el); - data.setAttribute('type', 'hidden'); - let changeFunction = function () { - let cv = data.value || ""; - let parts = cv.split(":"); - ais.value = (parts[0] == '0') ? "aisoff" : "aison"; - mode.value = (parts[1] == '0') ? "whitelist" : "blacklist"; - sentences.value = parts[2] || ""; - } - let updateFunction = function () { - let nv = (ais.value == 'aison') ? "1" : "0"; - nv += ":"; - nv += (mode.value == 'blacklist') ? "1" : "0"; - nv += ":"; - nv += sentences.value; - data.value = nv; - let chev = new Event('change'); - data.dispatchEvent(chev); - } - mode.addEventListener('change', updateFunction); - ais.addEventListener("change", updateFunction); - sentences.addEventListener("change", updateFunction); - data.addEventListener('change', function (ev) { - changeFunction(); - }); - data.setAttribute('name', configItem.name); - if (configItem.readOnly) data.setAttribute('disabled',true); - return data; -} -let moreicons=['icon-more','icon-less']; - -function collapseCategories(parent,expand){ - let doExpand=expand; - forEl('.category',function(cat){ - if (typeof(expand) === 'function') doExpand=expand(cat); - forEl('.content',function(content){ - if (doExpand){ - content.classList.remove('hidden'); - } - else{ - content.classList.add('hidden'); - } - },cat); - forEl('.title .icon',function(icon){ - toggleClass(icon,doExpand?1:0,moreicons); - },cat); - },parent); -} - -function findFreeXdr(data){ - let page=document.getElementById('xdrPage'); - let el=undefined; - collapseCategories(page,function(cat){ - if (el) return false; - let vEl=cat.querySelector('.xdrvalue'); - if (!vEl) return false; - if (isXdrUsed(vEl)) return false; - el=vEl; - if (data){ - el.value=data; - let ev=new Event('change'); - el.dispatchEvent(ev); - window.setTimeout(function(){ - cat.scrollIntoView(); - },50); - } - return true; - }); -} - -function convertUnassigned(value){ - let rt={}; - value=parseInt(value); - if (isNaN(value)) return; - //see GwXDRMappings::addUnknown - let instance=value & 0x1ff; - value = value >> 9; - let field=value & 0x7f; - value = value >> 7; - let selector=value & 0x7f; - value = value >> 7; - let cid=value & 0x7f; - let category=getXdrCategory(cid); - let cname=getXdrCategoryName(cid); - if (! cname) return rt; - let fieldName=""; - let idx=0; - (category.fields || []).forEach(function(f){ - if (f.v === undefined){ - if (idx == field) fieldName=f.l; - } - else{ - if (parseInt(f.v) == field) fieldName=f.l; - } - idx++; - }); - let selectorName=selector+""; - (category.selector ||[]).forEach(function(s){ - if (parseInt(s.v) == selector) selectorName=s.l; - }); - rt.l=cname+","+selectorName+","+fieldName+","+instance; - rt.v=cid+",1,"+selector+","+field+",0,"+instance+","; - return rt; -} - -function unassignedAdd(ev) { - let dv = ev.target.getAttribute('data-value'); - if (dv) { - findFreeXdr(dv); - hideOverlay(); - } -} -function loadUnassigned(){ - getText("/api/xdrUnmapped") - .then(function(txt){ - let ot=""; - txt.split('\n').forEach(function(line){ - let cv=convertUnassigned(line); - if (!cv || !cv.l) return; - ot+='<div class="xdrunassigned"><span>'+ - cv.l+'</span>'+ - '<button class="addunassigned" data-value="'+ - cv.v+ - '">+</button></div>'; - }) - showOverlay(ot,true); - forEl('.addunassigned',function(bt){ - bt.onclick=unassignedAdd; - }); - }) -} -function showXdrHelp(){ - let helpContent=document.getElementById('xdrHelp'); - if (helpContent){ - showOverlay(helpContent.innerHTML,true); - } -} -function formatDateForFilename(usePrefix,d){ - let rt=""; - if (usePrefix){ - let fwt=document.querySelector('.status-fwtype'); - if (fwt) rt=fwt.textContent; - rt+="_"; - } - if (! d) d=new Date(); - [d.getFullYear(),d.getMonth(),d.getDate(),d.getHours(),d.getMinutes(),d.getSeconds()] - .forEach(function(v){ - if (v < 10) rt+="0"+v; - else rt+=""+v; - }) - return rt; -} -function downloadData(data,name){ - let url="data:application/octet-stream,"+encodeURIComponent(JSON.stringify(data,undefined,2)); - let target=document.getElementById('downloadXdr'); - if (! target) return; - target.setAttribute('href',url); - target.setAttribute('download',name); - target.click(); -} -function exportConfig(){ - let data=getAllConfigs(true); - if (! data) return; - downloadData(data,formatDateForFilename(true)+".json"); -} -function exportXdr(){ - let data={}; - forEl('.xdrvalue',function(el) { - let name=el.getAttribute('name'); - let value=el.value; - let err=checkXDR(value,data); - if (err){ - alert("error in "+name+": "+value+"\n"+err); - return; - } - data[name]=value; - }) - downloadData(data,"xdr"+formatDateForFilename(true)+".json"); -} -function importJson(opt_keyPattern){ - let clazz='importJson'; - forEl('.'+clazz,function(ul){ - ul.remove(); - }); - let ip=addEl('input',clazz,document.body); - ip.setAttribute('type','file'); - ip.addEventListener('change',function(ev){ - if (ip.files.length > 0){ - let f=ip.files[0]; - let reader=new FileReader(); - reader.onloadend=function(status){ - try{ - let idata=JSON.parse(reader.result); - let hasOverwrites=false; - for (let k in idata){ - if (opt_keyPattern && ! k.match(opt_keyPattern)){ - alert("file contains invalid key "+k); - return; - } - let del=document.querySelector('[name='+k+']'); - if (del){ - hasOverwrites=true; - } + getJson('/api/status') + .then(function (jsonData) { + let statusPage = document.getElementById('statusPageContent'); + let even = true; //first counter + for (let k in jsonData) { + if (k == "salt") { + lastSalt = jsonData[k]; + continue; } - if (hasOverwrites){ - if (!confirm("overwrite existing data?")) return; + if (k == "minUser") { + minUser = parseInt(jsonData[k]); + continue; } - for (let k in idata){ - let del=document.querySelector('[name='+k+']'); - if (del){ - if (del.tagName === 'SELECT'){ - setSelect(del,idata[k]); - } - else{ - del.value=idata[k]; - } - let ev=new Event('change'); - del.dispatchEvent(ev); - } - } - }catch (error){ - alert("unable to parse upload: "+error); - return; - } - }; - reader.readAsBinaryString(f); - } - ip.remove(); - }); - ip.click(); -} -function importXdr(){ - importJson(new RegExp(/^XDR[0-9][0-9]*/)); -} -function importConfig(){ - importJson(); -} -function toggleClass(el,id,classList){ - let nc=classList[id]; - let rt=false; - if (nc && !el.classList.contains(nc)) rt=true; - for (let i in classList){ - if (i == id) continue; - el.classList.remove(classList[i]) - } - if (nc) el.classList.add(nc); - return rt; -} - -function createConfigDefinitions(parent, capabilities, defs,includeXdr) { - let categories={}; - let frame = parent.querySelector('.configFormRows'); - if (!frame) throw Error("no config form"); - frame.innerHTML = ''; - configDefinitions = defs; - let currentCategoryPopulated=true; - defs.forEach(function (item) { - if (!item.type) return; - if (item.category.match(/^xdr/)){ - if (! includeXdr) return; - } - else{ - if(includeXdr) return; - } - let catEntry; - if (categories[item.category] === undefined){ - catEntry={ - populated:false, - frame: undefined, - element: undefined - } - categories[item.category]=catEntry - catEntry.frame = addEl('div', 'category', frame); - catEntry.frame.setAttribute('data-category',item.category) - let categoryTitle = addEl('div', 'title', catEntry.frame); - let categoryButton = addEl('span', 'icon icon-more', categoryTitle); - addEl('span', 'label', categoryTitle, item.category); - addEl('span','categoryAdd',categoryTitle); - catEntry.element = addEl('div', 'content', catEntry.frame); - catEntry.element.classList.add('hidden'); - categoryTitle.addEventListener('click', function (ev) { - let rs = catEntry.element.classList.toggle('hidden'); - if (rs) { - toggleClass(categoryButton,0,moreicons); - } - else { - toggleClass(categoryButton,1,moreicons); - } - }) - } - else{ - catEntry=categories[item.category]; - } - let showItem=true; - let itemCapabilities=item.capabilities||{}; - itemCapabilities['HIDE'+item.name]=null; - for (let capability in itemCapabilities) { - let values = itemCapabilities[capability]; - let found = false; - if (! (values instanceof Array)) values=[values]; - values.forEach(function (v) { - if ( v === null){ - if (capabilities[capability] === undefined) found=true; - } - else{ - if (capabilities[capability] == v) found = true; - } - }); - if (!found) showItem=false; - } - let readOnly=false; - let mode=capabilities['CFGMODE'+item.name]; - if (mode == 1) { - //hide - showItem=false; - } - if (mode == 2){ - readOnly=true; - } - if (showItem) { - item.readOnly=readOnly; - catEntry.populated=true; - let row = addEl('div', 'row', catEntry.element); - let label = item.label || item.name; - addEl('span', 'label', row, label); - let valueFrame = addEl('div', 'value', row); - let valueEl = createInput(item, valueFrame); - if (!valueEl) return; - valueEl.setAttribute('data-default', item.default); - if (! readOnly) valueEl.addEventListener('change', function (ev) { - let el = ev.target; - checkChange(el, row, item.name); - }) - let condition = getConditions(item.name); - if (condition) { - condition.forEach(function (cel) { - for (let c in cel) { - if (!conditionRelations[c]) { - conditionRelations[c] = []; - } - conditionRelations[c].push(valueEl); - } - }) - } - if (item.check) valueEl.setAttribute('data-check', item.check); - let btContainer = addEl('div', 'buttonContainer', row); - if (!readOnly) { - let bt = addEl('button', 'defaultButton', btContainer, 'X'); - bt.setAttribute('data-default', item.default); - bt.addEventListener('click', function (ev) { - valueEl.value = valueEl.getAttribute('data-default'); - let changeEvent = new Event('change'); - valueEl.dispatchEvent(changeEvent); - }) - } - bt = addEl('button', 'infoButton', btContainer, '?'); - bt.addEventListener('click', function (ev) { - if (item.description) { - showOverlay(item.description); - } - else { - if (item.category.match(/^xdr/)) { - showXdrHelp(); - } - } - }); - } - }); - for (let cat in categories){ - let catEntry=categories[cat]; - if (! catEntry.populated){ - catEntry.frame.remove(); - } - } -} -function loadConfigDefinitions() { - getJson("api/capabilities") - .then(function (capabilities) { - if (capabilities.HELP_URL){ - let el=document.getElementById('helpButton'); - if (el) el.setAttribute('data-url',capabilities.HELP_URL); - } - getJson("config.json") - .then(function (defs) { - getJson("xdrconfig.json") - .then(function(xdr){ - xdrConfig=xdr; - configDefinitions=defs; - let normalConfig=document.getElementById('configPage'); - let xdrParent=document.getElementById('xdrPage'); - if (normalConfig) createConfigDefinitions(normalConfig,capabilities,defs,false); - if (xdrParent) createConfigDefinitions(xdrParent,capabilities,defs,true); - resetForm(); - getText('api/boatDataString').then(function (data) { - updateDashboard(data.split('\n')); - }); - }) - }) - }) - .catch(function (err) { alert("unable to load config: " + err) }) -} -function verifyPass(pass){ - return new Promise(function(resolve,reject){ - let hash=lastSalt+pass; - let md5hash=MD5(hash); - getJson('api/checkPass?hash='+encodeURIComponent(md5hash)) - .then(function(jsonData){ - if (jsonData.status == 'OK') resolve(md5hash); - else reject(jsonData.status); - return; - }) - .catch(function(error){reject(error);}) - }); -} - - -function adminPassCancel(){ - forEl('#adminPassOverlay',function(el){el.classList.add('hidden')}); - forEl('#adminPassInput',function(el){el.value=''}); -} -function saveAdminPass(pass,forceIfSet){ - forEl('#adminPassKeep',function(el){ - try{ - let old=localStorage.getItem('adminPass'); - if (el.value == 'true' || (forceIfSet && old !== undefined)){ - localStorage.setItem('adminPass',pass); - } - else{ - localStorage.removeItem('adminPass'); - } - }catch (e){} - }); -} -function forgetPass(){ - localStorage.removeItem('adminPass'); - forEl('#adminPassInput',function(el){ - el.value=''; - }); -} -function ensurePass(){ - return new Promise(function(resolve,reject){ - if (! needAdminPass) { - resolve(''); - return; - } - let pe=document.getElementById('adminPassInput'); - let hint=document.getElementById('adminPassError'); - if (!pe) { - reject('no input'); - return; - } - if (pe.value == ''){ - let ok=document.getElementById('adminPassOk'); - if (!ok) { - reject('no button'); - return; - } - ok.onclick=function(){ - verifyPass(pe.value) - .then(function(pass){ - forEl('#adminPassOverlay',function(el){el.classList.add('hidden')}); - saveAdminPass(pe.value); - resolve(pass); - }) - .catch(function(err){ - if (hint){ - hint.textContent="invalid password"; - } - }); - }; - if (hint) hint.textContent=''; - forEl('#adminPassOverlay',function(el){el.classList.remove('hidden')}); - } - else{ - verifyPass(pe.value) - .then(function(np){resolve(np);}) - .catch(function(err){ - pe.value=''; - ensurePass() - .then(function(p){resolve(p);}) - .catch(function(e){reject(e);}); - }); - return; - } - }); -} -function converterInfo() { - getJson("api/converterInfo").then(function (json) { - let text = "<h3>Converted entities</h3>"; - text += "<p><b>NMEA0183 to NMEA2000:</b><br/>"; - text += " " + (json.nmea0183 || "").replace(/,/g, ", "); - text += "</p>"; - text += "<p><b>NMEA2000 to NMEA0183:</b><br/>"; - text += " " + (json.nmea2000 || "").replace(/,/g, ", "); - text += "</p>"; - showOverlay(text, true); - }); -} -function handleTab(el) { - let activeName = el.getAttribute('data-page'); - if (!activeName) { - let extUrl= el.getAttribute('data-url'); - if (! extUrl) return; - window.open(extUrl,el.getAttribute('data-window')||'_'); - } - let activeTab = document.getElementById(activeName); - if (!activeTab) return; - let all = document.querySelectorAll('.tabPage'); - for (let i = 0; i < all.length; i++) { - all[i].classList.add('hidden'); - } - let tabs = document.querySelectorAll('.tab'); - for (let i = 0; i < all.length; i++) { - tabs[i].classList.remove('active'); - } - el.classList.add('active'); - activeTab.classList.remove('hidden'); -} -/** - * - * @param {number} coordinate - * @param axis - * @returns {string} - */ - function formatLonLatsDecimal(coordinate,axis){ - coordinate = (coordinate+540)%360 - 180; // normalize for sphere being round - - let abscoordinate = Math.abs(coordinate); - let coordinatedegrees = Math.floor(abscoordinate); - - let coordinateminutes = (abscoordinate - coordinatedegrees)/(1/60); - let numdecimal=2; - //correctly handle the toFixed(x) - will do math rounding - if (coordinateminutes.toFixed(numdecimal) == 60){ - coordinatedegrees+=1; - coordinateminutes=0; - } - if( coordinatedegrees < 10 ) { - coordinatedegrees = "0" + coordinatedegrees; - } - if (coordinatedegrees < 100 && axis == 'lon'){ - coordinatedegrees = "0" + coordinatedegrees; - } - let str = coordinatedegrees + "\u00B0"; - - if( coordinateminutes < 10 ) { - str +="0"; - } - str += coordinateminutes.toFixed(numdecimal) + "'"; - if (axis == "lon") { - str += coordinate < 0 ? "W" :"E"; - } else { - str += coordinate < 0 ? "S" :"N"; - } - return str; -}; -function formatFixed(v,dig,fract){ - v=parseFloat(v); - if (dig === undefined) return v.toFixed(fract); - let s=v<0?"-":""; - v=Math.abs(v); - let rv=v.toFixed(fract); - let parts=rv.split('.'); - parts[0]="0000000000"+parts[0]; - if (dig >= 10) dig=10; - if (fract > 0){ - return s+parts[0].substr(parts[0].length-dig)+"."+parts[1]; - } - return s+parts[0].substr(parts[0].length-dig); -} -let valueFormatters = { - formatCourse: { - f: function (v) { - let x = parseFloat(v); - let rt = x * 180.0 / Math.PI; - if (rt > 360) rt -= 360; - if (rt < 0) rt += 360; - return rt.toFixed(0); - }, - u: '°' - }, - formatKnots: { - f: function (v) { - let x = parseFloat(v); - x = x * 3600.0 / 1852.0; - return x.toFixed(2); - }, - u: 'kn' - }, - formatWind: { - f: function (v) { - let x = parseFloat(v); - x = x * 180.0 / Math.PI; - if (x > 180) x = -1 * (360 - x); - return x.toFixed(0); - }, - u: '°' - }, - mtr2nm: { - f: function (v) { - let x = parseFloat(v); - x = x / 1852.0; - return x.toFixed(2); - }, - u: 'nm' - }, - kelvinToC: { - f: function (v) { - let x = parseFloat(v); - x = x - 273.15; - return x.toFixed(0); - }, - u: '°' - }, - formatFixed0: { - f: function (v) { - let x = parseFloat(v); - return x.toFixed(0); - }, - u: '' - }, - formatDepth: { - f: function (v) { - let x = parseFloat(v); - return x.toFixed(1); - }, - u: 'm' - }, - formatLatitude: { - f: function (v) { - let x = parseFloat(v); - if (isNaN(x)) return '-----'; - return formatLonLatsDecimal(x,'lat'); - }, - u: '°' - }, - formatLongitude: { - f: function (v) { - let x = parseFloat(v); - if (isNaN(x)) return '-----'; - return formatLonLatsDecimal(x,'lon'); - }, - u: '' - }, - formatRot:{ - f: function(v){ - let x=parseFloat(v); - if (isNaN(x)) return '---'; - x = x * 180.0 / Math.PI; - return x.toFixed(2); - }, - u:'°/s' - }, - formatXte:{ - f: function(v){ - let x=parseFloat(v); - if (isNaN(x)) return '---'; - return x.toFixed(0); - }, - u:'m' - }, - formatDate:{ - f: function(v){ - v=parseFloat(v); - if (isNaN(v)) return "----/--/--"; - //strange day offset from NMEA0183 lib - let d=new Date("2010/01/01"); - let days=14610-d.getTime()/1000/86400; - let tbase=(v-days)*1000*86400; - let od=new Date(tbase); - return formatFixed(od.getFullYear(),4,0)+ - "/"+formatFixed(od.getMonth()+1,2,0)+ - "/"+formatFixed(od.getDate(),2,0); - }, - u:'' - }, - formatTime:{ - f:function(v){ - v=parseFloat(v); - if (isNaN(v)) return "--:--:--"; - let hr=Math.floor(v/3600.0); - let min=Math.floor((v-hr*3600.0)/60); - let s=Math.floor((v-hr*3600.0-min*60.0)); - return formatFixed(hr,2,0)+':'+ - formatFixed(min,2,0)+':'+ - formatFixed(s,2,0); - }, - u:'' - } - - -} -function resizeFont(el,reset,maxIt){ - if (maxIt === undefined) maxIt=10; - if (! el) return; - if (reset) el.style.fontSize=''; - while (el.scrollWidth > el.clientWidth && maxIt){ - let next=parseFloat(window.getComputedStyle(el).fontSize)*0.9; - el.style.fontSize=next+"px"; - } -} -function createDashboardItem(name, def, parent) { - if (! def.name) return; - let frame = addEl('div', 'dash', parent); - let title = addEl('span', 'dashTitle', frame, name); - let value = addEl('span', 'dashValue', frame); - value.setAttribute('id', 'data_' + name); - let fmt=valueFormatters[def.format]; - if (def.format) value.classList.add(def.format); - let footer = addEl('div','footer',frame); - let src= addEl('span','source',footer); - src.setAttribute('id','source_'+name); - let u=fmt?fmt.u:' '; - if (! fmt && def.format && def.format.match(/formatXdr/)){ - u=def.format.replace(/formatXdr:[^:]*:/,''); - } - addEl('span','unit',footer,u); - return value; -} -function parseBoatDataLine(line){ - let rt={}; - let parts=line.split(','); - rt.name=parts[0]; - rt.valid=parts[2] === '1'; - rt.update=parseInt(parts[3]); - rt.source=parseInt(parts[4]); - rt.format=parts[1]; - rt.value=parts[5]; - return rt; -} -function createDashboard() { - let frame = document.getElementById('dashboardPage'); - if (!frame) return; - frame.innerHTML = ''; -} -function sourceName(v){ - if (v == 0) return "N2K"; - for (let n in channelList){ - if (v == channelList[n].id) return n; - if (v >= channelList[n].id && v <= channelList[n].max){ - return n; - } - } - if (v < minUser) return "---"; - return "USER["+v+"]"; -} -let lastSelectList=[]; -function updateDashboard(data) { - let frame = document.getElementById('dashboardPage'); - let showInvalid=true; - forEl('select[name=showInvalidData]',function(el){ - if (el.value == 'false') showInvalid=false; - }) - let names={}; - for (let n in data) { - let current=parseBoatDataLine(data[n]); - if (! current.name) continue; - names[current.name]=true; - let de = document.getElementById('data_' + current.name); - let isValid=current.valid; - if (! de && frame && (isValid || showInvalid)){ - de=createDashboardItem(current.name,current,frame); - } - if (de && (!isValid && !showInvalid)){ - de.parentElement.remove(); - continue; - } - if (de) { - let newContent='----'; - if (current.valid) { - let formatter; - if (current.format && current.format != "NULL") { - let key = current.format.replace(/^\&/, ''); - formatter = valueFormatters[key]; - } - if (formatter) { - newContent = formatter.f(current.value); - } - else { - let v = parseFloat(current.value); - if (!isNaN(v)) { - v = v.toFixed(3) - newContent = v; - } - else { - newContent = current.value; - } - } - } - else newContent = "---"; - if (newContent !== de.textContent){ - de.textContent=newContent; - resizeFont(de,true); - } - } - let src=document.getElementById('source_'+current.name); - if (src){ - src.textContent=sourceName(current.source); - } - } - console.log("update"); - forEl('.dashValue',function(el){ - let id=el.getAttribute('id'); - if (id){ - if (! names[id.replace(/^data_/,'')]){ - el.parentElement.remove(); - } - } - }); - let selectList=[]; - for (let n in names){ - selectList.push({l:n,v:n}); - } - let selectChanged=false; - if (lastSelectList.length == selectList.length){ - for (let i=0;i<lastSelectList.length;i++){ - if (selectList[i] != lastSelectList[i]){ - selectChanged=true; - break; - } - } - } - else{ - selectChanged=true; - } - if (selectChanged){ - forEl('.boatDataSelect',function(el){ - updateSelectList(el,selectList,true); - }); - } -} -function uploadBin(ev){ - let el=document.getElementById("uploadFile"); - let progressEl=document.getElementById("uploadDone"); - if (! el) return; - if ( el.files.length < 1) return; - ev.target.disabled=true; - let file=el.files[0]; - checkImageFile(file) - .then(function (result) { - let currentType; - let currentVersion; - let chipid; - forEl('.status-version', function (el) { currentVersion = el.textContent }); - forEl('.status-fwtype', function (el) { currentType = el.textContent }); - forEl('.status-chipid', function (el) { chipid = el.textContent }); - let confirmText = 'Ready to update firmware?\n'; - if (result.chipId != chipid){ - confirmText += "WARNING: the chipid in the image ("+result.chipId; - confirmText +=") does not match the current chip id ("+chipid+").\n"; - } - if (currentType != result.fwtype) { - confirmText += "WARNING: image has different type: " + result.fwtype + "\n"; - confirmText += "** Really update anyway? - device can become unusable **"; - } - else { - if (currentVersion == result.version) { - confirmText += "WARNING: image has the same version as current " + result.version; - } - else { - confirmText += "version in image: " + result.version; - } - } - if (!confirm(confirmText)) { - ev.target.disabled=false; - return; - } - ensurePass() - .then(function (hash) { - let len = file.size; - let req = new XMLHttpRequest(); - req.onloadend = function () { - ev.target.disabled=false; - let result = "unknown error"; - try { - let jresult = JSON.parse(req.responseText); - if (jresult.status == 'OK') { - result = ''; - } - else { - if (jresult.status) { - result = jresult.status; + if (!statusPage) continue; + if (typeof (jsonData[k]) === 'object') { + if (k.indexOf('count') == 0) { + createCounterDisplay(statusPage, k.replace("count", "").replace(/in$/, " in").replace(/out$/, " out"), k, even); + even = !even; + for (let sk in jsonData[k]) { + let key = k + "." + sk; + if (typeof (jsonData[k][sk]) === 'object') { + //msg details + updateMsgDetails(key, jsonData[k][sk]); + } + else { + let el = document.getElementById(key); + if (el) el.textContent = jsonData[k][sk]; } } - } catch (e) { - result = "Error " + req.status; } - if (progressEl) { - progressEl.style.width = 0; + if (k.indexOf("ch") == 0) { + //channel def + let name = k.substring(2); + channelList[name] = jsonData[k]; } - if (!result) { + } + else { + let el = document.getElementById(k); + if (el) el.textContent = jsonData[k]; + forEl('.status-' + k, function (el) { + el.textContent = jsonData[k]; + }); + } + } + lastUpdate = (new Date()).getTime(); + if (reloadConfig) { + reloadConfig = false; + resetForm(); + } + }) + } + function resetForm(ev) { + getJson("/api/config") + .then(function (jsonData) { + for (let k in jsonData) { + if (k == "useAdminPass") { + needAdminPass = jsonData[k] != 'false'; + } + let el = document.querySelector("[name='" + k + "']"); + if (el) { + let v = jsonData[k]; + let def = getConfigDefition(k); + if (def.check == 'checkMinMax') { + //simple migration if the current value is outside the range + //we even "hide" this from the user + v = parseFloat(v); + if (!isNaN(v)) { + if (def.min !== undefined) { + if (v < parseFloat(def.min)) v = parseFloat(def.min); + } + if (def.max !== undefined) { + if (v > parseFloat(def.max)) v = parseFloat(def.max); + } + } + } + if (el.tagName === 'SELECT') { + setSelect(el, v); + } + else { + el.value = v; + } + el.setAttribute('data-loaded', v); + let changeEvent = new Event('change'); + el.dispatchEvent(changeEvent); + } + } + let name = jsonData.systemName; + if (name) { + let el = document.getElementById('headline'); + if (el) el.textContent = name; + document.title = name; + } + }); + } + function checkMinMax(v, allValues, def) { + let parsed = parseFloat(v); + if (isNaN(parsed)) return "must be a number"; + if (def.min !== undefined) { + if (parsed < parseFloat(def.min)) return "must be >= " + def.min; + } + if (def.max !== undefined) { + if (parsed > parseFloat(def.max)) return "must be <= " + def.max; + } + } + + function checkSystemName(v) { + //2...32 characters for ssid + let allowed = v.replace(/[^a-zA-Z0-9]*/g, ''); + if (allowed != v) return "contains invalid characters, only a-z, A-Z, 0-9"; + if (v.length < 2 || v.length > 32) return "invalid length (2...32)"; + } + function checkApPass(v) { + //min 8 characters + if (v.length < 8) { + return "password must be at least 8 characters"; + } + } + function checkAdminPass(v) { + return checkApPass(v); + } + + function checkApIp(v, allValues) { + if (!v) return "cannot be empty"; + let err1 = "must be in the form 192.168.x.x"; + if (!v.match(/[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/)) return err1; + let parts = v.split("."); + if (parts.length != 4) return err1; + for (let idx = 0; idx < 4; idx++) { + let iv = parseInt(parts[idx]); + if (iv < 0 || iv > 255) return err1; + } + } + function checkNetMask(v, allValues) { + return checkApIp(v, allValues); + } + + function checkIpAddress(v, allValues, def) { + if (allValues.tclEnabled != "true") return; + if (!v) return "cannot be empty"; + if (!v.match(/[0-9]*\.[0-9]*\.[0-9]*\.[0-9]*/) + && !v.match(/.*\.local/)) + return "must be either in the form 192.168.1.1 or xxx.local"; + } + + function checkXDR(v, allValues) { + if (!v) return; + let parts = v.split(','); + if (parseInt(parts[1]) == 0) return; + if (parseInt(parts[1]) != 0 && !parts[6]) return "missing transducer name"; + for (let k in allValues) { + if (!k.match(/^XDR/)) continue; + let cmp = allValues[k]; + if (cmp == v) { + return "same mapping already defined in " + k; + } + let cmpParts = cmp.split(','); + if (parseInt(cmpParts[1]) != 0 && parts[6] == cmpParts[6]) { + return "transducer " + parts[6] + " already defined in " + k; + } + //check similar mappings + if (parts[0] == cmpParts[0] && parts[2] == cmpParts[2] && parts[3] == cmpParts[3]) { + if (parts[4] == cmpParts[4] && parts[5] == cmpParts[5]) { + return "mapping for the same entity already defined in " + k; + } + if ((parseInt(parts[4]) == 0 && parseInt(cmpParts[4]) == 1) || + (parseInt(parts[4]) == 1 && parseInt(cmpParts[4]) == 0) + ) { + //ignore and single for the same mapping + return "mapping for the same entity already defined in " + k; + } + } + } + } + let loggedChecks={}; + function getAllConfigs(omitPass) { + let values = document.querySelectorAll('.configForm select , .configForm input'); + let allValues = {}; + for (let i = 0; i < values.length; i++) { + let v = values[i]; + let name = v.getAttribute('name'); + if (!name) continue; + if (name.indexOf("_") >= 0) continue; + if (v.getAttribute('disabled')) continue; + let def = getConfigDefition(name); + if (def.type === 'password' && (v.value == '' || omitPass)) { + continue; + } + let check = v.getAttribute('data-check'); + if (check) { + let checkFunction; + try{ + checkFunction=eval(check); + }catch(e){} + if (typeof (checkFunction) === 'function') { + if (! loggedChecks[check]){ + loggedChecks[check]=true; + console.log("check:"+check); + } + let res = checkFunction(v.value, allValues, getConfigDefition(name)); + if (res) { + let value = v.value; + if (v.type === 'password') value = "******"; + let label = v.getAttribute('data-label'); + if (!label) label = v.getAttribute('name'); + v.classList.add("error"); + alert("invalid config for " + label + "(" + value + "):\n" + res); + return; + } + } + } + allValues[name] = v.value; + } + return allValues; + } + function changeConfig() { + ensurePass() + .then(function (pass) { + let newAdminPass; + let url = "/api/setConfig" + let body = "_hash=" + encodeURIComponent(pass) + "&"; + let allValues = getAllConfigs(); + if (!allValues) return; + for (let name in allValues) { + if (name == 'adminPassword') { + newAdminPass = allValues[name]; + } + body += encodeURIComponent(name) + "=" + encodeURIComponent(allValues[name]) + "&"; + } + fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream' //we must lie here + }, + body: body + }) + .then((rs) => rs.json()) + .then(function (status) { + if (status.status == 'OK') { + if (newAdminPass !== undefined) { + forEl('#adminPassInput', function (el) { + el.valu = newAdminPass; + }); + saveAdminPass(newAdminPass, true); + } alertRestart(); } else { - alert("update error: " + result); + alert("unable to set config: " + status.status); } - } - req.onerror = function (e) { - ev.target.disabled=false; - alert("unable to upload: " + e); - } - if (progressEl) { - progressEl.style.width = 0; - req.upload.onprogress = function (ev) { - if (ev.lengthComputable) { - let percent = 100 * ev.loaded / ev.total; - progressEl.style.width = percent + "%"; - } - } - } - let formData = new FormData(); - formData.append("file1", el.files[0]); - req.open("POST", '/api/update?_hash=' + encodeURIComponent(hash)); - req.send(formData); - }) - .catch(function (e) { - ev.target.disabled=false; - }); - }) - .catch(function (e) { - alert("This file is an invalid image file:\n" + e); - ev.target.disabled=false; - }) -} -let HDROFFSET=288; -let VERSIONOFFSET=16; -let NAMEOFFSET=48; -let MINSIZE = HDROFFSET + NAMEOFFSET + 32; -let CHIPIDOFFSET=12; //2 bytes chip id here -let imageCheckBytes={ - 0: 0xe9, //image magic - 288: 0x32, //app header magic - 289: 0x54, - 290: 0xcd, - 291: 0xab -}; -function decodeFromBuffer(buffer,start,length){ - while (length > 0 && buffer[start+length-1] == 0){ - length--; + }) + }) + .catch(function (e) { alert(e); }) } - if (length <= 0) return ""; - let decoder=new TextDecoder(); - let rt=decoder.decode(buffer.slice( - start, - start+length)); - return rt; -} -function getChipId(buffer){ - if (buffer.length < CHIPIDOFFSET+2) return -1; - return buffer[CHIPIDOFFSET]+256*buffer[CHIPIDOFFSET+1]; -} -function checkImageFile(file){ - return new Promise(function(resolve,reject){ - if (! file) reject("no file"); - if (file.size < MINSIZE) reject("file is too small"); - let slice=file.slice(0,MINSIZE); - let reader=new FileReader(); - reader.addEventListener('load',function(e){ - let content=new Uint8Array(e.target.result); - for (let idx in imageCheckBytes){ - if (content[idx] != imageCheckBytes[idx] ){ - reject("missing magic byte at position "+idx+", expected "+ - imageCheckBytes[idx]+", got "+content[idx]); + function factoryReset() { + ensurePass() + .then(function (hash) { + if (!confirm("Really delete all configuration?\n" + + "This will reset all your Wifi settings and disconnect you.")) { + return; + } + getJson("/api/resetConfig?_hash=" + encodeURIComponent(hash)) + .then(function (status) { + alertRestart(); + }) + }) + .catch(function (e) { }); + } + function createCounterDisplay(parent, label, key, isEven) { + if (parent.querySelector("#" + key)) { + return; + } + let clazz = "row icon-row counter-row"; + if (isEven) clazz += " even"; + let row = addEl('div', clazz, parent); + row.setAttribute("id", key); + let icon = addEl('span', 'icon icon-more', row); + addEl('span', 'label', row, label); + let value = addEl('span', 'value', row, '---'); + value.setAttribute('id', key + ".sumOk"); + let display = addEl('div', clazz + " msgDetails hidden", parent); + display.setAttribute('id', key + ".ok"); + row.addEventListener('click', function (ev) { + let rs = display.classList.toggle('hidden'); + if (rs) { + icon.classList.add('icon-more'); + icon.classList.remove('icon-less'); + } + else { + icon.classList.remove('icon-more'); + icon.classList.add('icon-less'); + } + }); + } + function validKey(key) { + if (!key) return; + return key.replace(/[^a-z_:A-Z0-9-]/g, ''); + } + function updateMsgDetails(key, details) { + forEl('.msgDetails', function (frame) { + if (frame.getAttribute('id') !== key) return; + for (let k in details) { + k = validKey(k); + let el = frame.querySelector("[data-id=\"" + k + "\"] "); + if (!el) { + el = addEl('div', 'row', frame); + let cv = addEl('span', 'label', el, k); + cv = addEl('span', 'value', el, details[k]); + cv.setAttribute('data-id', k); + } + else { + el.textContent = details[k]; + } + } + forEl('.value', function (el) { + let k = el.getAttribute('data-id'); + if (k && !details[k]) { + el.parentElement.remove(); + } + }, frame); + }); + } + + function showOverlay(text, isHtml, buttons) { + let el = document.getElementById('overlayContent'); + if (text instanceof Object) { + el.textContent = ''; + el.appendChild(text); + } + else { + if (isHtml) { + el.innerHTML = text; + el.classList.remove("text"); + } + else { + el.textContent = text; + el.classList.add("text"); } } - let version=decodeFromBuffer(content,HDROFFSET+VERSIONOFFSET,32); - let fwtype=decodeFromBuffer(content,HDROFFSET+NAMEOFFSET,32); - let chipId=getChipId(content); - let rt={ - fwtype:fwtype, - version: version, - chipId:chipId - }; - resolve(rt); - }); - reader.readAsArrayBuffer(slice); - }); -} -window.setInterval(update, 1000); -window.setInterval(function () { - let dp = document.getElementById('dashboardPage'); - if (dp.classList.contains('hidden')) return; - getText('api/boatDataString').then(function (data) { - updateDashboard(data.split('\n')); - }); -}, 1000); -window.addEventListener('load', function () { - let buttons = document.querySelectorAll('button'); - for (let i = 0; i < buttons.length; i++) { - let be = buttons[i]; - be.onclick = window[be.id]; //assume a function with the button id - } - forEl('.showMsgDetails', function (cd) { - cd.addEventListener('change', function (ev) { - let key = ev.target.getAttribute('data-key'); - if (!key) return; - let el = document.getElementById(key); - if (!el) return; - if (ev.target.checked) el.classList.remove('hidden'); - else (el.classList).add('hidden'); - }); - }); - let tabs = document.querySelectorAll('.tab'); - for (let i = 0; i < tabs.length; i++) { - tabs[i].addEventListener('click', function (ev) { - handleTab(ev.target); + buttons = (buttons ? buttons : []).concat([{ label: "Close", click: hideOverlay }]); + let container = document.getElementById('overlayContainer'); + let btframe = container.querySelector('.overlayButtons'); + btframe.textContent = ''; + buttons.forEach((btconfig) => { + let bt = addEl('button', '', btframe, btconfig.label); + bt.addEventListener("click", btconfig.click); }); + container.classList.remove('hidden'); } - createDashboard(); - loadConfigDefinitions(); - try{ - let storedPass=localStorage.getItem('adminPass'); - if (storedPass){ - forEl('#adminPassInput',function(el){ - el.value=storedPass; + function hideOverlay() { + let container = document.getElementById('overlayContainer'); + container.classList.add('hidden'); + let el = document.getElementById('overlayContent'); + el.textContent = ''; + } + function checkChange(el, row, name) { + el.classList.remove("error"); + let loaded = el.getAttribute('data-loaded'); + if (loaded !== undefined) { + if (loaded != el.value) { + row.classList.add('changed'); + } + else { + row.classList.remove("changed"); + } + } + let dependend = conditionRelations[name]; + if (dependend) { + for (let el in dependend) { + checkCondition(dependend[el]); + } + } + } + let configDefinitions = {}; + let xdrConfig = {}; + //a map between the name of a config item and a list of dependend items + let conditionRelations = {}; + function getConfigDefition(name) { + if (!name) return {}; + let def; + for (let k in configDefinitions) { + if (configDefinitions[k].name === name) { + def = configDefinitions[k]; + break; + } + } + if (!def) return {}; + return def; + } + function getConditions(name) { + let def = getConfigDefition(name); + if (!def) return; + let condition = def.condition; + if (!condition) return; + if (!(condition instanceof Array)) condition = [condition]; + return condition; + } + function checkCondition(element) { + let name = element.getAttribute('name'); + let condition = getConditions(name); + if (!condition) return; + let visible = false; + if (!condition instanceof Array) condition = [condition]; + condition.forEach(function (cel) { + let lvis = true; + for (let k in cel) { + let item = document.querySelector('[name=' + k + ']'); + if (item) { + let compare = cel[k]; + if (compare instanceof Array) { + if (compare.indexOf(item.value) < 0) lvis = false; + } + else { + if (item.value != cel[k]) lvis = false; + } + } + } + if (lvis) visible = true; + }); + let row = closestParent(element, 'row'); + if (!row) return; + if (visible) row.classList.remove('hidden'); + else row.classList.add('hidden'); + } + let caliv = 0; + function createCalSetInput(configItem, frame, clazz) { + let el = addEl('input', clazz, frame); + let cb = addEl('button', '', frame, 'C'); + //el.disabled=true; + cb.addEventListener('click', (ev) => { + let cs = document.getElementById("calset").cloneNode(true); + cs.classList.remove("hidden"); + cs.querySelector(".heading").textContent = configItem.label || configItem.name; + let vel = cs.querySelector(".val"); + if (caliv != 0) window.clearInterval(caliv); + caliv = window.setInterval(() => { + if (document.body.contains(cs)) { + fetch("/api/calibrate?name=" + encodeURIComponent(configItem.name)) + .then((r) => r.text()) + .then((txt) => { + if (txt != vel.textContent) { + vel.textContent = txt; + } + }) + .catch((e) => { + alert(e); + hideOverlay(); + window.clearInterval(caliv); + }) + } + else { + window.clearInterval(caliv); + } + }, 200); + showOverlay(cs, false, [{ + label: 'Set', click: () => { + el.value = vel.textContent; + let cev = new Event('change'); + el.dispatchEvent(cev); + } + }]); + }) + el.setAttribute('name', configItem.name) + return el; + } + function createCalValInput(configItem, frame, clazz) { + let el = addEl('input', clazz, frame); + let cb = addEl('button', '', frame, 'C'); + //el.disabled=true; + cb.addEventListener('click', (ev) => { + const sv = function (val, cfg) { + if (configItem.eval) { + let v = parseFloat(val); + let c = parseFloat(cfg); + return (eval(configItem.eval)); + } + return v; + }; + let cs = document.getElementById("calval").cloneNode(true); + cs.classList.remove("hidden"); + cs.querySelector(".heading").textContent = configItem.label || configItem.name; + let vel = cs.querySelector(".val"); + let vinp = cs.querySelector("input"); + vinp.value = el.value; + if (caliv != 0) window.clearInterval(caliv); + caliv = window.setInterval(() => { + if (document.body.contains(cs)) { + fetch("/api/calibrate?name=" + encodeURIComponent(configItem.name)) + .then((r) => r.text()) + .then((txt) => { + txt = sv(txt, vinp.value); + if (txt != vel.textContent) { + vel.textContent = txt; + } + }) + .catch((e) => { + alert(e); + hideOverlay(); + window.clearInterval(caliv); + }) + } + else { + window.clearInterval(caliv); + } + }, 200); + showOverlay(cs, false, [{ + label: 'Set', click: () => { + el.value = vinp.value; + let cev = new Event('change'); + el.dispatchEvent(cev); + } + }]); + }) + el.setAttribute('name', configItem.name) + return el; + } + function createInput(configItem, frame, clazz) { + let el; + if (configItem.type === 'boolean' || configItem.type === 'list' || configItem.type == 'boatData') { + el = addEl('select', clazz, frame); + if (configItem.readOnly) el.setAttribute('disabled', true); + el.setAttribute('name', configItem.name) + let slist = []; + if (configItem.list) { + configItem.list.forEach(function (v) { + if (v instanceof Object) { + slist.push({ l: v.l, v: v.v }); + } + else { + slist.push({ l: v, v: v }); + } + }) + } + else if (configItem.type != 'boatData') { + slist.push({ l: 'on', v: 'true' }) + slist.push({ l: 'off', v: 'false' }) + } + slist.forEach(function (sitem) { + let sitemEl = addEl('option', '', el, sitem.l); + sitemEl.setAttribute('value', sitem.v); + }) + if (configItem.type == 'boatData') { + el.classList.add('boatDataSelect'); + } + return el; + } + if (configItem.type === 'filter') { + return createFilterInput(configItem, frame, clazz); + } + if (configItem.type === 'xdr') { + return createXdrInput(configItem, frame, clazz); + } + if (configItem.type === "calset") { + return createCalSetInput(configItem, frame, clazz); + } + if (configItem.type === "calval") { + return createCalValInput(configItem, frame, clazz); + } + el = addEl('input', clazz, frame); + if (configItem.readOnly) el.setAttribute('disabled', true); + el.setAttribute('name', configItem.name) + if (configItem.type === 'password') { + el.setAttribute('type', 'password'); + let vis = addEl('span', 'icon-eye icon', frame); + vis.addEventListener('click', function (v) { + if (vis.classList.toggle('active')) { + el.setAttribute('type', 'text'); + } + else { + el.setAttribute('type', 'password'); + } }); } - }catch(e){} - let statusPage=document.getElementById('statusPageContent'); - /*if (statusPage){ - let even=true; - for (let c in counters){ - createCounterDisplay(statusPage,counters[c],c,even); - even=!even; + else if (configItem.type === 'number') { + el.setAttribute('type', 'number'); } - }*/ - forEl('#uploadFile',function(el){ - el.addEventListener('change',function(ev){ - if (ev.target.files.length < 1) return; - let file=ev.target.files[0]; - checkImageFile(file) - .then(function(res){ - forEl('#imageProperties',function(iel){ - let txt="["+res.chipId+"] "; - txt+=res.fwtype+", "+res.version; - iel.textContent=txt; - iel.classList.remove("error"); - }) - }) - .catch(function(e){ - forEl('#imageProperties',function(iel){ - iel.textContent=e; - iel.classList.add("error"); - }) - }) + else { + el.setAttribute('type', 'text'); + } + return el; + } + + function setSelect(item, value) { + if (!item) return; + item.value = value; + if (item.value !== value) { + //missing option with his value + let o = addEl('option', undefined, item, value); + o.setAttribute('value', value); + item.value = value; + } + } + + function updateSelectList(item, slist, opt_keepValue) { + let idx = 0; + let value; + if (opt_keepValue) value = item.value; + item.innerHTML = ''; + slist.forEach(function (sitem) { + let sitemEl = addEl('option', '', item, sitem.l); + sitemEl.setAttribute('value', sitem.v !== undefined ? sitem.v : idx); + idx++; }) - }) -}); \ No newline at end of file + if (value !== undefined) { + setSelect(item, value); + } + } + function getXdrCategories() { + let rt = []; + for (let c in xdrConfig) { + if (xdrConfig[c].enabled !== false) { + rt.push({ l: c, v: xdrConfig[c].id }); + } + } + return rt; + } + function getXdrCategoryName(cid) { + category = parseInt(cid); + for (let c in xdrConfig) { + let base = xdrConfig[c]; + if (parseInt(base.id) == category) { + return c; + } + } + } + function getXdrCategory(cid) { + category = parseInt(cid); + for (let c in xdrConfig) { + let base = xdrConfig[c]; + if (parseInt(base.id) == category) { + return base; + } + } + return {}; + } + function getXdrSelectors(category) { + let base = getXdrCategory(category); + return base.selector || []; + } + function getXdrFields(category) { + let base = getXdrCategory(category); + if (!base.fields) return []; + let rt = []; + base.fields.forEach(function (f) { + if (f.t === undefined) return; + if (parseInt(f.t) == 99) return; //unknown type + rt.push(f); + }); + return rt; + } + + function createXdrLine(parent, label) { + let d = addEl('div', 'xdrline', parent); + addEl('span', 'xdrlabel', d, label); + return d; + } + function showHideXdr(el, show, useParent) { + if (useParent) el = el.parentElement; + if (show) el.classList.remove('xdrunused'); + else el.classList.add('xdrunused'); + } + + function createXdrInput(configItem, frame) { + let configCategory = configItem.category; + let el = addEl('div', 'xdrinput', frame); + let d = createXdrLine(el, 'Direction'); + let direction = createInput({ + type: 'list', + name: configItem.name + "_dir", + list: [ + //GwXDRMappingDef::Direction + { l: 'off', v: 0 }, + { l: 'bidir', v: 1 }, + { l: 'to2K', v: 2 }, + { l: 'from2K', v: 3 } + ], + readOnly: configItem.readOnly + }, d, 'xdrdir'); + d = createXdrLine(el, 'Category'); + let category = createInput({ + type: 'list', + name: configItem.name + "_cat", + list: getXdrCategories(), + readOnly: configItem.readOnly + }, d, 'xdrcat'); + d = createXdrLine(el, 'Source'); + let selector = createInput({ + type: 'list', + name: configItem.name + "_sel", + list: [], + readOnly: configItem.readOnly + }, d, 'xdrsel'); + d = createXdrLine(el, 'Field'); + let field = createInput({ + type: 'list', + name: configItem.name + '_field', + list: [], + readOnly: configItem.readOnly + }, d, 'xdrfield'); + d = createXdrLine(el, 'Instance'); + let imode = createInput({ + type: 'list', + name: configItem.name + "_imode", + list: [ + //GwXDRMappingDef::InstanceMode + { l: 'single', v: 0 }, + { l: 'ignore', v: 1 }, + { l: 'auto', v: 2 } + ], + readOnly: configItem.readOnly + }, d, 'xdrimode'); + let instance = createInput({ + type: 'number', + name: configItem.name + "_instance", + readOnly: configItem.readOnly + }, d, 'xdrinstance'); + d = createXdrLine(el, 'Transducer'); + let xdrName = createInput({ + type: 'text', + name: configItem.name + "_xdr", + readOnly: configItem.readOnly + }, d, 'xdrname'); + d = createXdrLine(el, 'Example'); + let example = addEl('div', 'xdrexample', d, ''); + let data = addEl('input', 'xdrvalue', el); + data.setAttribute('type', 'hidden'); + data.setAttribute('name', configItem.name); + if (configItem.readOnly) data.setAttribute('disabled', true); + let changeFunction = function () { + let parts = data.value.split(','); + direction.value = parts[1] || 0; + category.value = parts[0] || 0; + let selectors = getXdrSelectors(category.value); + updateSelectList(selector, selectors); + showHideXdr(selector, selectors.length > 0); + let fields = getXdrFields(category.value); + updateSelectList(field, fields); + showHideXdr(field, fields.length > 0); + selector.value = parts[2] || 0; + field.value = parts[3] || 0; + imode.value = parts[4] || 0; + instance.value = parts[5] || 0; + showHideXdr(instance, imode.value == 0); + xdrName.value = parts[6] || ''; + let used = isXdrUsed(data); + let modified = data.value != data.getAttribute('data-loaded'); + forEl('[data-category=' + configCategory + ']', function (el) { + if (used) { + el.classList.add('xdrcused'); + el.classList.remove('xdrcunused'); + forEl('.categoryAdd', function (add) { + add.textContent = xdrName.value; + }, el); + } + else { + el.classList.remove('xdrcused'); + el.classList.add('xdrcunused'); + forEl('.categoryAdd', function (add) { + add.textContent = ''; + }, el); + } + if (modified) { + el.classList.add('changed'); + } + else { + el.classList.remove('changed'); + } + }); + if (used) { + getText('/api/xdrExample?mapping=' + encodeURIComponent(data.value) + '&value=2.1') + .then(function (txt) { + example.textContent = txt; + }) + } + else { + example.textContent = ''; + } + } + let updateFunction = function (evt) { + if (evt.target == category) { + selector.value = 0; + field.value = 0; + instance.value = 0; + } + let txt = category.value + "," + direction.value + "," + + selector.value + "," + field.value + "," + imode.value; + let instanceValue = parseInt(instance.value || 0); + if (isNaN(instanceValue)) instanceValue = 0; + if (instanceValue < 0) instanceValue = 0; + if (instanceValue > 255) instanceValue = 255; + txt += "," + instanceValue; + let xdr = xdrName.value.replace(/[^a-zA-Z0-9]/g, ''); + xdr = xdr.substr(0, 12); + txt += "," + xdr; + data.value = txt; + let ev = new Event('change'); + data.dispatchEvent(ev); + } + category.addEventListener('change', updateFunction); + direction.addEventListener('change', updateFunction); + selector.addEventListener('change', updateFunction); + field.addEventListener('change', updateFunction); + imode.addEventListener('change', updateFunction); + instance.addEventListener('change', updateFunction); + xdrName.addEventListener('change', updateFunction); + data.addEventListener('change', changeFunction); + return data; + } + + function isXdrUsed(element) { + let parts = element.value.split(','); + if (!parts[1]) return false; + if (!parseInt(parts[1])) return false; + if (!parts[6]) return false; + return true; + } + + function createFilterInput(configItem, frame) { + let el = addEl('div', 'filter', frame); + let ais = createInput({ + type: 'list', + name: configItem.name + "_ais", + list: ['aison', 'aisoff'], + readOnly: configItem.readOnly + }, el); + let mode = createInput({ + type: 'list', + name: configItem.name + "_mode", + list: ['whitelist', 'blacklist'], + readOnly: configItem.readOnly + }, el); + let sentences = createInput({ + type: 'text', + name: configItem.name + "_sentences", + readOnly: configItem.readOnly + }, el); + let data = addEl('input', undefined, el); + data.setAttribute('type', 'hidden'); + let changeFunction = function () { + let cv = data.value || ""; + let parts = cv.split(":"); + ais.value = (parts[0] == '0') ? "aisoff" : "aison"; + mode.value = (parts[1] == '0') ? "whitelist" : "blacklist"; + sentences.value = parts[2] || ""; + } + let updateFunction = function () { + let nv = (ais.value == 'aison') ? "1" : "0"; + nv += ":"; + nv += (mode.value == 'blacklist') ? "1" : "0"; + nv += ":"; + nv += sentences.value; + data.value = nv; + let chev = new Event('change'); + data.dispatchEvent(chev); + } + mode.addEventListener('change', updateFunction); + ais.addEventListener("change", updateFunction); + sentences.addEventListener("change", updateFunction); + data.addEventListener('change', function (ev) { + changeFunction(); + }); + data.setAttribute('name', configItem.name); + if (configItem.readOnly) data.setAttribute('disabled', true); + return data; + } + let moreicons = ['icon-more', 'icon-less']; + + function collapseCategories(parent, expand) { + let doExpand = expand; + forEl('.category', function (cat) { + if (typeof (expand) === 'function') doExpand = expand(cat); + forEl('.content', function (content) { + if (doExpand) { + content.classList.remove('hidden'); + } + else { + content.classList.add('hidden'); + } + }, cat); + forEl('.title .icon', function (icon) { + toggleClass(icon, doExpand ? 1 : 0, moreicons); + }, cat); + }, parent); + } + + function findFreeXdr(data) { + let page = document.getElementById('xdrPage'); + let el = undefined; + collapseCategories(page, function (cat) { + if (el) return false; + let vEl = cat.querySelector('.xdrvalue'); + if (!vEl) return false; + if (isXdrUsed(vEl)) return false; + el = vEl; + if (data) { + el.value = data; + let ev = new Event('change'); + el.dispatchEvent(ev); + window.setTimeout(function () { + cat.scrollIntoView(); + }, 50); + } + return true; + }); + } + + function convertUnassigned(value) { + let rt = {}; + value = parseInt(value); + if (isNaN(value)) return; + //see GwXDRMappings::addUnknown + let instance = value & 0x1ff; + value = value >> 9; + let field = value & 0x7f; + value = value >> 7; + let selector = value & 0x7f; + value = value >> 7; + let cid = value & 0x7f; + let category = getXdrCategory(cid); + let cname = getXdrCategoryName(cid); + if (!cname) return rt; + let fieldName = ""; + let idx = 0; + (category.fields || []).forEach(function (f) { + if (f.v === undefined) { + if (idx == field) fieldName = f.l; + } + else { + if (parseInt(f.v) == field) fieldName = f.l; + } + idx++; + }); + let selectorName = selector + ""; + (category.selector || []).forEach(function (s) { + if (parseInt(s.v) == selector) selectorName = s.l; + }); + rt.l = cname + "," + selectorName + "," + fieldName + "," + instance; + rt.v = cid + ",1," + selector + "," + field + ",0," + instance + ","; + return rt; + } + + function unassignedAdd(ev) { + let dv = ev.target.getAttribute('data-value'); + if (dv) { + findFreeXdr(dv); + hideOverlay(); + } + } + function loadUnassigned() { + getText("/api/xdrUnmapped") + .then(function (txt) { + let ot = ""; + txt.split('\n').forEach(function (line) { + let cv = convertUnassigned(line); + if (!cv || !cv.l) return; + ot += '<div class="xdrunassigned"><span>' + + cv.l + '</span>' + + '<button class="addunassigned" data-value="' + + cv.v + + '">+</button></div>'; + }) + showOverlay(ot, true); + forEl('.addunassigned', function (bt) { + bt.onclick = unassignedAdd; + }); + }) + } + function showXdrHelp() { + let helpContent = document.getElementById('xdrHelp'); + if (helpContent) { + showOverlay(helpContent.innerHTML, true); + } + } + function formatDateForFilename(usePrefix, d) { + let rt = ""; + if (usePrefix) { + let fwt = document.querySelector('.status-fwtype'); + if (fwt) rt = fwt.textContent; + rt += "_"; + } + if (!d) d = new Date(); + [d.getFullYear(), d.getMonth(), d.getDate(), d.getHours(), d.getMinutes(), d.getSeconds()] + .forEach(function (v) { + if (v < 10) rt += "0" + v; + else rt += "" + v; + }) + return rt; + } + function downloadData(data, name) { + let url = "data:application/octet-stream," + encodeURIComponent(JSON.stringify(data, undefined, 2)); + let target = document.getElementById('downloadXdr'); + if (!target) return; + target.setAttribute('href', url); + target.setAttribute('download', name); + target.click(); + } + function exportConfig() { + let data = getAllConfigs(true); + if (!data) return; + downloadData(data, formatDateForFilename(true) + ".json"); + } + function exportXdr() { + let data = {}; + forEl('.xdrvalue', function (el) { + let name = el.getAttribute('name'); + let value = el.value; + let err = checkXDR(value, data); + if (err) { + alert("error in " + name + ": " + value + "\n" + err); + return; + } + data[name] = value; + }) + downloadData(data, "xdr" + formatDateForFilename(true) + ".json"); + } + function importJson(opt_keyPattern) { + let clazz = 'importJson'; + forEl('.' + clazz, function (ul) { + ul.remove(); + }); + let ip = addEl('input', clazz, document.body); + ip.setAttribute('type', 'file'); + ip.addEventListener('change', function (ev) { + if (ip.files.length > 0) { + let f = ip.files[0]; + let reader = new FileReader(); + reader.onloadend = function (status) { + try { + let idata = JSON.parse(reader.result); + let hasOverwrites = false; + for (let k in idata) { + if (opt_keyPattern && !k.match(opt_keyPattern)) { + alert("file contains invalid key " + k); + return; + } + let del = document.querySelector('[name=' + k + ']'); + if (del) { + hasOverwrites = true; + } + } + if (hasOverwrites) { + if (!confirm("overwrite existing data?")) return; + } + for (let k in idata) { + let del = document.querySelector('[name=' + k + ']'); + if (del) { + if (del.tagName === 'SELECT') { + setSelect(del, idata[k]); + } + else { + del.value = idata[k]; + } + let ev = new Event('change'); + del.dispatchEvent(ev); + } + } + } catch (error) { + alert("unable to parse upload: " + error); + return; + } + }; + reader.readAsBinaryString(f); + } + ip.remove(); + }); + ip.click(); + } + function importXdr() { + importJson(new RegExp(/^XDR[0-9][0-9]*/)); + } + function importConfig() { + importJson(); + } + function toggleClass(el, id, classList) { + let nc = classList[id]; + let rt = false; + if (nc && !el.classList.contains(nc)) rt = true; + for (let i in classList) { + if (i == id) continue; + el.classList.remove(classList[i]) + } + if (nc) el.classList.add(nc); + return rt; + } + + function createConfigDefinitions(parent, capabilities, defs, includeXdr) { + let categories = {}; + let frame = parent.querySelector('.configFormRows'); + if (!frame) throw Error("no config form"); + frame.innerHTML = ''; + configDefinitions = defs; + let currentCategoryPopulated = true; + defs.forEach(function (item) { + if (!item.type || item.category === undefined) return; + if (item.category.match(/^xdr/)) { + if (!includeXdr) return; + } + else { + if (includeXdr) return; + } + let catEntry; + if (categories[item.category] === undefined) { + catEntry = { + populated: false, + frame: undefined, + element: undefined + } + categories[item.category] = catEntry + catEntry.frame = addEl('div', 'category', frame); + catEntry.frame.setAttribute('data-category', item.category) + let categoryTitle = addEl('div', 'title', catEntry.frame); + let categoryButton = addEl('span', 'icon icon-more', categoryTitle); + addEl('span', 'label', categoryTitle, item.category); + addEl('span', 'categoryAdd', categoryTitle); + catEntry.element = addEl('div', 'content', catEntry.frame); + catEntry.element.classList.add('hidden'); + categoryTitle.addEventListener('click', function (ev) { + let rs = catEntry.element.classList.toggle('hidden'); + if (rs) { + toggleClass(categoryButton, 0, moreicons); + } + else { + toggleClass(categoryButton, 1, moreicons); + } + }) + } + else { + catEntry = categories[item.category]; + } + let showItem = true; + let itemCapabilities = item.capabilities || {}; + itemCapabilities['HIDE' + item.name] = null; + for (let capability in itemCapabilities) { + let values = itemCapabilities[capability]; + let found = false; + if (!(values instanceof Array)) values = [values]; + values.forEach(function (v) { + if (v === null) { + if (capabilities[capability] === undefined) found = true; + } + else { + if (capabilities[capability] == v) found = true; + } + }); + if (!found) showItem = false; + } + let readOnly = false; + let mode = capabilities['CFGMODE' + item.name]; + if (mode == 1) { + //hide + showItem = false; + } + if (mode == 2) { + readOnly = true; + } + if (showItem) { + item.readOnly = readOnly; + catEntry.populated = true; + let row = addEl('div', 'row', catEntry.element); + let label = item.label || item.name; + addEl('span', 'label', row, label); + let valueFrame = addEl('div', 'value', row); + let valueEl = createInput(item, valueFrame); + if (!valueEl) return; + valueEl.setAttribute('data-default', item.default); + if (!readOnly) valueEl.addEventListener('change', function (ev) { + let el = ev.target; + checkChange(el, row, item.name); + }) + let condition = getConditions(item.name); + if (condition) { + condition.forEach(function (cel) { + for (let c in cel) { + if (!conditionRelations[c]) { + conditionRelations[c] = []; + } + conditionRelations[c].push(valueEl); + } + }) + } + if (item.check) valueEl.setAttribute('data-check', item.check); + valueEl.setAttribute('data-label', label); + let btContainer = addEl('div', 'buttonContainer', row); + if (!readOnly) { + let bt = addEl('button', 'defaultButton', btContainer, 'X'); + bt.setAttribute('data-default', item.default); + bt.addEventListener('click', function (ev) { + valueEl.value = valueEl.getAttribute('data-default'); + let changeEvent = new Event('change'); + valueEl.dispatchEvent(changeEvent); + }) + } + bt = addEl('button', 'infoButton', btContainer, '?'); + bt.addEventListener('click', function (ev) { + if (item.description) { + showOverlay(item.description); + } + else { + if (item.category.match(/^xdr/)) { + showXdrHelp(); + } + } + }); + } + }); + for (let cat in categories) { + let catEntry = categories[cat]; + if (!catEntry.populated) { + catEntry.frame.remove(); + } + } + } + function loadConfigDefinitions() { + getJson("api/capabilities") + .then(function (capabilities) { + if (capabilities.HELP_URL) { + let el = document.getElementById('helpButton'); + if (el) el.setAttribute('data-url', capabilities.HELP_URL); + } + try{ + Object.freeze(capabilities); + callListeners(api.EVENTS.capabilities,capabilities); + }catch (e){ + console.log(e); + } + getJson("config.json") + .then(function (defs) { + getJson("xdrconfig.json") + .then(function (xdr) { + xdrConfig = xdr; + configDefinitions = defs; + let normalConfig = document.getElementById('configPage'); + let xdrParent = document.getElementById('xdrPage'); + if (normalConfig) createConfigDefinitions(normalConfig, capabilities, defs, false); + if (xdrParent) createConfigDefinitions(xdrParent, capabilities, defs, true); + resetForm(); + getText('api/boatDataString').then(function (data) { + updateDashboard(data.split('\n')); + }); + }) + }) + }) + .catch(function (err) { alert("unable to load config: " + err) }) + } + function verifyPass(pass) { + return new Promise(function (resolve, reject) { + let hash = lastSalt + pass; + let md5hash = MD5(hash); + getJson('api/checkPass?hash=' + encodeURIComponent(md5hash)) + .then(function (jsonData) { + if (jsonData.status == 'OK') resolve(md5hash); + else reject(jsonData.status); + return; + }) + .catch(function (error) { reject(error); }) + }); + } + + + function adminPassCancel() { + forEl('#adminPassOverlay', function (el) { el.classList.add('hidden') }); + forEl('#adminPassInput', function (el) { el.value = '' }); + } + function saveAdminPass(pass, forceIfSet) { + forEl('#adminPassKeep', function (el) { + try { + let old = localStorage.getItem('adminPass'); + if (el.value == 'true' || (forceIfSet && old !== undefined)) { + localStorage.setItem('adminPass', pass); + } + else { + localStorage.removeItem('adminPass'); + } + } catch (e) { } + }); + } + function forgetPass() { + localStorage.removeItem('adminPass'); + forEl('#adminPassInput', function (el) { + el.value = ''; + }); + } + function ensurePass() { + return new Promise(function (resolve, reject) { + if (!needAdminPass) { + resolve(''); + return; + } + let pe = document.getElementById('adminPassInput'); + let hint = document.getElementById('adminPassError'); + if (!pe) { + reject('no input'); + return; + } + if (pe.value == '') { + let ok = document.getElementById('adminPassOk'); + if (!ok) { + reject('no button'); + return; + } + ok.onclick = function () { + verifyPass(pe.value) + .then(function (pass) { + forEl('#adminPassOverlay', function (el) { el.classList.add('hidden') }); + saveAdminPass(pe.value); + resolve(pass); + }) + .catch(function (err) { + if (hint) { + hint.textContent = "invalid password"; + } + }); + }; + if (hint) hint.textContent = ''; + forEl('#adminPassOverlay', function (el) { el.classList.remove('hidden') }); + } + else { + verifyPass(pe.value) + .then(function (np) { resolve(np); }) + .catch(function (err) { + pe.value = ''; + ensurePass() + .then(function (p) { resolve(p); }) + .catch(function (e) { reject(e); }); + }); + return; + } + }); + } + function converterInfo() { + getJson("api/converterInfo").then(function (json) { + let text = "<h3>Converted entities</h3>"; + text += "<p><b>NMEA0183 to NMEA2000:</b><br/>"; + text += " " + (json.nmea0183 || "").replace(/,/g, ", "); + text += "</p>"; + text += "<p><b>NMEA2000 to NMEA0183:</b><br/>"; + text += " " + (json.nmea2000 || "").replace(/,/g, ", "); + text += "</p>"; + showOverlay(text, true); + }); + } + function handleTab(el) { + let activeName = el.getAttribute('data-page'); + if (!activeName) { + let extUrl = el.getAttribute('data-url'); + if (!extUrl) return; + window.open(extUrl, el.getAttribute('data-window') || '_'); + } + let activeTab = document.getElementById(activeName); + if (!activeTab) return; + let all = document.querySelectorAll('.tabPage'); + for (let i = 0; i < all.length; i++) { + all[i].classList.add('hidden'); + } + let tabs = document.querySelectorAll('.tab'); + for (let i = 0; i < all.length; i++) { + tabs[i].classList.remove('active'); + } + el.classList.add('active'); + activeTab.classList.remove('hidden'); + } + /** + * + * @param {number} coordinate + * @param axis + * @returns {string} + */ + function formatLonLatsDecimal(coordinate, axis) { + coordinate = (coordinate + 540) % 360 - 180; // normalize for sphere being round + + let abscoordinate = Math.abs(coordinate); + let coordinatedegrees = Math.floor(abscoordinate); + + let coordinateminutes = (abscoordinate - coordinatedegrees) / (1 / 60); + let numdecimal = 2; + //correctly handle the toFixed(x) - will do math rounding + if (coordinateminutes.toFixed(numdecimal) == 60) { + coordinatedegrees += 1; + coordinateminutes = 0; + } + if (coordinatedegrees < 10) { + coordinatedegrees = "0" + coordinatedegrees; + } + if (coordinatedegrees < 100 && axis == 'lon') { + coordinatedegrees = "0" + coordinatedegrees; + } + let str = coordinatedegrees + "\u00B0"; + + if (coordinateminutes < 10) { + str += "0"; + } + str += coordinateminutes.toFixed(numdecimal) + "'"; + if (axis == "lon") { + str += coordinate < 0 ? "W" : "E"; + } else { + str += coordinate < 0 ? "S" : "N"; + } + return str; + }; + function formatFixed(v, dig, fract) { + v = parseFloat(v); + if (dig === undefined) return v.toFixed(fract); + let s = v < 0 ? "-" : ""; + v = Math.abs(v); + let rv = v.toFixed(fract); + let parts = rv.split('.'); + parts[0] = "0000000000" + parts[0]; + if (dig >= 10) dig = 10; + if (fract > 0) { + return s + parts[0].substr(parts[0].length - dig) + "." + parts[1]; + } + return s + parts[0].substr(parts[0].length - dig); + } + let valueFormatters = { + formatCourse: { + f: function (v) { + let x = parseFloat(v); + let rt = x * 180.0 / Math.PI; + if (rt > 360) rt -= 360; + if (rt < 0) rt += 360; + return rt.toFixed(0); + }, + u: '°' + }, + formatKnots: { + f: function (v) { + let x = parseFloat(v); + x = x * 3600.0 / 1852.0; + return x.toFixed(2); + }, + u: 'kn' + }, + formatWind: { + f: function (v) { + let x = parseFloat(v); + x = x * 180.0 / Math.PI; + if (x > 180) x = -1 * (360 - x); + return x.toFixed(0); + }, + u: '°' + }, + mtr2nm: { + f: function (v) { + let x = parseFloat(v); + x = x / 1852.0; + return x.toFixed(2); + }, + u: 'nm' + }, + kelvinToC: { + f: function (v) { + let x = parseFloat(v); + x = x - 273.15; + return x.toFixed(0); + }, + u: '°' + }, + formatFixed0: { + f: function (v) { + let x = parseFloat(v); + return x.toFixed(0); + }, + u: '' + }, + formatDepth: { + f: function (v) { + let x = parseFloat(v); + return x.toFixed(1); + }, + u: 'm' + }, + formatLatitude: { + f: function (v) { + let x = parseFloat(v); + if (isNaN(x)) return '-----'; + return formatLonLatsDecimal(x, 'lat'); + }, + u: '°' + }, + formatLongitude: { + f: function (v) { + let x = parseFloat(v); + if (isNaN(x)) return '-----'; + return formatLonLatsDecimal(x, 'lon'); + }, + u: '' + }, + formatRot: { + f: function (v) { + let x = parseFloat(v); + if (isNaN(x)) return '---'; + x = x * 180.0 / Math.PI; + return x.toFixed(2); + }, + u: '°/s' + }, + formatXte: { + f: function (v) { + let x = parseFloat(v); + if (isNaN(x)) return '---'; + return x.toFixed(0); + }, + u: 'm' + }, + formatDate: { + f: function (v) { + v = parseFloat(v); + if (isNaN(v)) return "----/--/--"; + //strange day offset from NMEA0183 lib + let d = new Date("2010/01/01"); + let days = 14610 - d.getTime() / 1000 / 86400; + let tbase = (v - days) * 1000 * 86400; + let od = new Date(tbase); + return formatFixed(od.getFullYear(), 4, 0) + + "/" + formatFixed(od.getMonth() + 1, 2, 0) + + "/" + formatFixed(od.getDate(), 2, 0); + }, + u: '' + }, + formatTime: { + f: function (v) { + v = parseFloat(v); + if (isNaN(v)) return "--:--:--"; + let hr = Math.floor(v / 3600.0); + let min = Math.floor((v - hr * 3600.0) / 60); + let s = Math.floor((v - hr * 3600.0 - min * 60.0)); + return formatFixed(hr, 2, 0) + ':' + + formatFixed(min, 2, 0) + ':' + + formatFixed(s, 2, 0); + }, + u: '' + } + + + } + function resizeFont(el, reset, maxIt) { + if (maxIt === undefined) maxIt = 10; + if (!el) return; + if (reset) el.style.fontSize = ''; + while (el.scrollWidth > el.clientWidth && maxIt) { + let next = parseFloat(window.getComputedStyle(el).fontSize) * 0.9; + el.style.fontSize = next + "px"; + } + } + function createDashboardItem(name, def, parent) { + if (!def.name) return; + let frame = addEl('div', 'dash', parent); + let title = addEl('span', 'dashTitle', frame, name); + let value = addEl('span', 'dashValue', frame); + value.setAttribute('id', 'data_' + name); + let fmt = valueFormatters[def.format]; + if (def.format) value.classList.add(def.format); + let footer = addEl('div', 'footer', frame); + let src = addEl('span', 'source', footer); + src.setAttribute('id', 'source_' + name); + let u = fmt ? fmt.u : ' '; + if (!fmt && def.format && def.format.match(/formatXdr/)) { + u = def.format.replace(/formatXdr:[^:]*:/, ''); + } + addEl('span', 'unit', footer, u); + return value; + } + function parseBoatDataLine(line) { + let rt = {}; + let parts = line.split(','); + rt.name = parts[0]; + rt.valid = parts[2] === '1'; + rt.update = parseInt(parts[3]); + rt.source = parseInt(parts[4]); + rt.format = parts[1]; + rt.value = parts[5]; + return rt; + } + function createDashboard() { + let frame = document.getElementById('dashboardPage'); + if (!frame) return; + frame.innerHTML = ''; + } + function sourceName(v) { + if (v == 0) return "N2K"; + for (let n in channelList) { + if (v == channelList[n].id) return n; + if (v >= channelList[n].id && v <= channelList[n].max) { + return n; + } + } + if (v < minUser) return "---"; + return "USER[" + v + "]"; + } + let lastSelectList = []; + function updateDashboard(data) { + let frame = document.getElementById('dashboardPage'); + let showInvalid = true; + forEl('select[name=showInvalidData]', function (el) { + if (el.value == 'false') showInvalid = false; + }) + let names = {}; + for (let n in data) { + let current = parseBoatDataLine(data[n]); + if (!current.name) continue; + names[current.name] = true; + let de = document.getElementById('data_' + current.name); + let isValid = current.valid; + if (!de && frame && (isValid || showInvalid)) { + de = createDashboardItem(current.name, current, frame); + } + if (de && (!isValid && !showInvalid)) { + de.parentElement.remove(); + continue; + } + if (de) { + let newContent = '----'; + if (current.valid) { + let formatter; + if (current.format && current.format != "NULL") { + let key = current.format.replace(/^\&/, ''); + formatter = valueFormatters[key]; + } + if (formatter) { + newContent = formatter.f(current.value); + } + else { + let v = parseFloat(current.value); + if (!isNaN(v)) { + v = v.toFixed(3) + newContent = v; + } + else { + newContent = current.value; + } + } + } + else newContent = "---"; + if (newContent !== de.textContent) { + de.textContent = newContent; + resizeFont(de, true); + } + } + let src = document.getElementById('source_' + current.name); + if (src) { + src.textContent = sourceName(current.source); + } + } + console.log("update"); + forEl('.dashValue', function (el) { + let id = el.getAttribute('id'); + if (id) { + if (!names[id.replace(/^data_/, '')]) { + el.parentElement.remove(); + } + } + }); + let selectList = []; + for (let n in names) { + selectList.push({ l: n, v: n }); + } + let selectChanged = false; + if (lastSelectList.length == selectList.length) { + for (let i = 0; i < lastSelectList.length; i++) { + if (selectList[i] != lastSelectList[i]) { + selectChanged = true; + break; + } + } + } + else { + selectChanged = true; + } + if (selectChanged) { + forEl('.boatDataSelect', function (el) { + updateSelectList(el, selectList, true); + }); + } + } + function uploadBin(ev) { + let el = document.getElementById("uploadFile"); + let progressEl = document.getElementById("uploadDone"); + if (!el) return; + if (el.files.length < 1) return; + ev.target.disabled = true; + let file = el.files[0]; + checkImageFile(file) + .then(function (result) { + let currentType; + let currentVersion; + let chipid; + forEl('.status-version', function (el) { currentVersion = el.textContent }); + forEl('.status-fwtype', function (el) { currentType = el.textContent }); + forEl('.status-chipid', function (el) { chipid = el.textContent }); + let confirmText = 'Ready to update firmware?\n'; + if (result.chipId != chipid) { + confirmText += "WARNING: the chipid in the image (" + result.chipId; + confirmText += ") does not match the current chip id (" + chipid + ").\n"; + } + if (currentType != result.fwtype) { + confirmText += "WARNING: image has different type: " + result.fwtype + "\n"; + confirmText += "** Really update anyway? - device can become unusable **"; + } + else { + if (currentVersion == result.version) { + confirmText += "WARNING: image has the same version as current " + result.version; + } + else { + confirmText += "version in image: " + result.version; + } + } + if (!confirm(confirmText)) { + ev.target.disabled = false; + return; + } + ensurePass() + .then(function (hash) { + let len = file.size; + let req = new XMLHttpRequest(); + req.onloadend = function () { + ev.target.disabled = false; + let result = "unknown error"; + try { + let jresult = JSON.parse(req.responseText); + if (jresult.status == 'OK') { + result = ''; + } + else { + if (jresult.status) { + result = jresult.status; + } + } + } catch (e) { + result = "Error " + req.status; + } + if (progressEl) { + progressEl.style.width = 0; + } + if (!result) { + alertRestart(); + } + else { + alert("update error: " + result); + } + } + req.onerror = function (e) { + ev.target.disabled = false; + alert("unable to upload: " + e); + } + if (progressEl) { + progressEl.style.width = 0; + req.upload.onprogress = function (ev) { + if (ev.lengthComputable) { + let percent = 100 * ev.loaded / ev.total; + progressEl.style.width = percent + "%"; + } + } + } + let formData = new FormData(); + formData.append("file1", el.files[0]); + req.open("POST", '/api/update?_hash=' + encodeURIComponent(hash)); + req.send(formData); + }) + .catch(function (e) { + ev.target.disabled = false; + }); + }) + .catch(function (e) { + alert("This file is an invalid image file:\n" + e); + ev.target.disabled = false; + }) + } + let HDROFFSET = 288; + let VERSIONOFFSET = 16; + let NAMEOFFSET = 48; + let MINSIZE = HDROFFSET + NAMEOFFSET + 32; + let CHIPIDOFFSET = 12; //2 bytes chip id here + let imageCheckBytes = { + 0: 0xe9, //image magic + 288: 0x32, //app header magic + 289: 0x54, + 290: 0xcd, + 291: 0xab + }; + function decodeFromBuffer(buffer, start, length) { + while (length > 0 && buffer[start + length - 1] == 0) { + length--; + } + if (length <= 0) return ""; + let decoder = new TextDecoder(); + let rt = decoder.decode(buffer.slice( + start, + start + length)); + return rt; + } + function getChipId(buffer) { + if (buffer.length < CHIPIDOFFSET + 2) return -1; + return buffer[CHIPIDOFFSET] + 256 * buffer[CHIPIDOFFSET + 1]; + } + function checkImageFile(file) { + return new Promise(function (resolve, reject) { + if (!file) reject("no file"); + if (file.size < MINSIZE) reject("file is too small"); + let slice = file.slice(0, MINSIZE); + let reader = new FileReader(); + reader.addEventListener('load', function (e) { + let content = new Uint8Array(e.target.result); + for (let idx in imageCheckBytes) { + if (content[idx] != imageCheckBytes[idx]) { + reject("missing magic byte at position " + idx + ", expected " + + imageCheckBytes[idx] + ", got " + content[idx]); + } + } + let version = decodeFromBuffer(content, HDROFFSET + VERSIONOFFSET, 32); + let fwtype = decodeFromBuffer(content, HDROFFSET + NAMEOFFSET, 32); + let chipId = getChipId(content); + let rt = { + fwtype: fwtype, + version: version, + chipId: chipId + }; + resolve(rt); + }); + reader.readAsArrayBuffer(slice); + }); + } + const api= { + registerListener: function (callback) { + listeners.push(callback); + }, + addEl: addEl, + forEl: forEl, + closestParent: closestParent, + EVENTS: { + init: 0, + capabilities: 1, //called when capabilities are loaded + } + }; + function callListeners(event,data){ + listeners.forEach((listener)=>{ + if (typeof(listener) === 'function'){ + listener(event,data); + } + }) + } + window.esp32nmea = api; + window.setInterval(update, 1000); + window.setInterval(function () { + let dp = document.getElementById('dashboardPage'); + if (dp.classList.contains('hidden')) return; + getText('api/boatDataString').then(function (data) { + updateDashboard(data.split('\n')); + }); + }, 1000); + window.addEventListener('load', function () { + let buttons = document.querySelectorAll('button'); + for (let i = 0; i < buttons.length; i++) { + let be = buttons[i]; + let buttonFunction=eval(be.id); + if (typeof(buttonFunction) === 'function'){ + be.onclick = buttonFunction; //assume a function with the button id + console.log("button: "+be.id); + } + else{ + console.log("no handler for button "+be.id); + } + } + forEl('.showMsgDetails', function (cd) { + cd.addEventListener('change', function (ev) { + let key = ev.target.getAttribute('data-key'); + if (!key) return; + let el = document.getElementById(key); + if (!el) return; + if (ev.target.checked) el.classList.remove('hidden'); + else (el.classList).add('hidden'); + }); + }); + let tabs = document.querySelectorAll('.tab'); + for (let i = 0; i < tabs.length; i++) { + tabs[i].addEventListener('click', function (ev) { + handleTab(ev.target); + }); + } + createDashboard(); + loadConfigDefinitions(); + try { + let storedPass = localStorage.getItem('adminPass'); + if (storedPass) { + forEl('#adminPassInput', function (el) { + el.value = storedPass; + }); + } + } catch (e) { } + let statusPage = document.getElementById('statusPageContent'); + /*if (statusPage){ + let even=true; + for (let c in counters){ + createCounterDisplay(statusPage,counters[c],c,even); + even=!even; + } + }*/ + forEl('#uploadFile', function (el) { + el.addEventListener('change', function (ev) { + if (ev.target.files.length < 1) return; + let file = ev.target.files[0]; + checkImageFile(file) + .then(function (res) { + forEl('#imageProperties', function (iel) { + let txt = "[" + res.chipId + "] "; + txt += res.fwtype + ", " + res.version; + iel.textContent = txt; + iel.classList.remove("error"); + }) + }) + .catch(function (e) { + forEl('#imageProperties', function (iel) { + iel.textContent = e; + iel.classList.add("error"); + }) + }) + }) + }) + callListeners(api.EVENTS.init); + }); +}()); \ No newline at end of file From 98a95e62f7f9cee031e360c81d4f13eaf7b0032f Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Fri, 11 Oct 2024 20:13:45 +0200 Subject: [PATCH 25/58] first version for user js tasks --- lib/exampletask/index.js | 12 +++++++ tools/testServer.py | 14 +++++++- web/index.css | 4 +-- web/index.js | 71 ++++++++++++++++++++-------------------- 4 files changed, 63 insertions(+), 38 deletions(-) create mode 100644 lib/exampletask/index.js diff --git a/lib/exampletask/index.js b/lib/exampletask/index.js new file mode 100644 index 0000000..0224a73 --- /dev/null +++ b/lib/exampletask/index.js @@ -0,0 +1,12 @@ +(function(){ + let isActive=false; + window.esp32nmea2k.registerListener((id,data)=>{ + if (id === 0){ + //data is capabilities + if (data.testboard) isActive=true; + } + if (isActive){ + console.log("exampletask listener",id,data); + } + }) +})(); diff --git a/tools/testServer.py b/tools/testServer.py index ecbfb4d..d00c6cd 100755 --- a/tools/testServer.py +++ b/tools/testServer.py @@ -55,8 +55,17 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler): super().do_GET() def do_POST(self): if not self.do_proxy(): - super().do_POST() + super().do_POST() + def guess_type(self,path): + if path.endswith('.gz'): + return super().guess_type(path[0:-3]) + return super().guess_type(path) + def end_headers(self): + if hasattr(self,"isgz") and self.isgz: + self.send_header("Content-Encoding","gzip") + super().end_headers() def translate_path(self, path): + self.isgz=False """Translate a /-separated PATH to the local filename syntax. Components that mean special things to the local file system @@ -90,6 +99,9 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler): rpath += '/' if os.path.exists(rpath): return rpath + if os.path.exists(rpath+".gz"): + self.isgz=True + return rpath+".gz" if isSecond: return rpath isSecond=True diff --git a/web/index.css b/web/index.css index aada09f..eb3b1ca 100644 --- a/web/index.css +++ b/web/index.css @@ -341,6 +341,6 @@ body { .error{ color: red; } -.changed input.error{ - color: red; +input.error{ + background-color: rgba(255, 0, 0, 0.329); } \ No newline at end of file diff --git a/web/index.js b/web/index.js index d61556b..1d02096 100644 --- a/web/index.js +++ b/web/index.js @@ -1,5 +1,4 @@ (function () { - let self = this; let lastUpdate = (new Date()).getTime(); let reloadConfig = false; let needAdminPass = true; @@ -7,6 +6,8 @@ let channelList = {}; let minUser = 200; let listeners = []; + let buttonHandlers={}; + let checkers={}; function addEl(type, clazz, parent, text) { let el = document.createElement(type); if (clazz) { @@ -48,7 +49,7 @@ return fetch(url) .then(function (r) { return r.text() }); } - function reset() { + buttonHandlers.reset=function() { ensurePass() .then(function (hash) { fetch('/api/reset?_hash=' + encodeURIComponent(hash)); @@ -161,7 +162,8 @@ } }); } - function checkMinMax(v, allValues, def) { + buttonHandlers.resetForm=resetForm; + checkers.checkMinMax=function(v, allValues, def) { let parsed = parseFloat(v); if (isNaN(parsed)) return "must be a number"; if (def.min !== undefined) { @@ -172,7 +174,7 @@ } } - function checkSystemName(v) { + checkers.checkSystemName=function(v) { //2...32 characters for ssid let allowed = v.replace(/[^a-zA-Z0-9]*/g, ''); if (allowed != v) return "contains invalid characters, only a-z, A-Z, 0-9"; @@ -184,11 +186,11 @@ return "password must be at least 8 characters"; } } - function checkAdminPass(v) { + checkers.checkAdminPass=function(v) { return checkApPass(v); } - function checkApIp(v, allValues) { + checkers.checkApIp=function(v, allValues) { if (!v) return "cannot be empty"; let err1 = "must be in the form 192.168.x.x"; if (!v.match(/[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/)) return err1; @@ -199,11 +201,11 @@ if (iv < 0 || iv > 255) return err1; } } - function checkNetMask(v, allValues) { - return checkApIp(v, allValues); + checkers.checkNetMask=function(v, allValues) { + return checkers.checkApIp(v, allValues); } - function checkIpAddress(v, allValues, def) { + checkers.checkIpAddress=function(v, allValues, def) { if (allValues.tclEnabled != "true") return; if (!v) return "cannot be empty"; if (!v.match(/[0-9]*\.[0-9]*\.[0-9]*\.[0-9]*/) @@ -211,7 +213,7 @@ return "must be either in the form 192.168.1.1 or xxx.local"; } - function checkXDR(v, allValues) { + checkers.checkXDR=function(v, allValues) { if (!v) return; let parts = v.split(','); if (parseInt(parts[1]) == 0) return; @@ -256,14 +258,11 @@ } let check = v.getAttribute('data-check'); if (check) { - let checkFunction; - try{ - checkFunction=eval(check); - }catch(e){} + let checkFunction=checkers[check]; if (typeof (checkFunction) === 'function') { if (! loggedChecks[check]){ loggedChecks[check]=true; - console.log("check:"+check); + //console.log("check:"+check); } let res = checkFunction(v.value, allValues, getConfigDefition(name)); if (res) { @@ -276,12 +275,15 @@ return; } } + else{ + console.log("check not found:",check); + } } allValues[name] = v.value; } return allValues; } - function changeConfig() { + buttonHandlers.changeConfig=function() { ensurePass() .then(function (pass) { let newAdminPass; @@ -320,7 +322,7 @@ }) .catch(function (e) { alert(e); }) } - function factoryReset() { + buttonHandlers.factoryReset=function() { ensurePass() .then(function (hash) { if (!confirm("Really delete all configuration?\n" + @@ -1014,7 +1016,7 @@ hideOverlay(); } } - function loadUnassigned() { + buttonHandlers.loadUnassigned=function() { getText("/api/xdrUnmapped") .then(function (txt) { let ot = ""; @@ -1062,17 +1064,17 @@ target.setAttribute('download', name); target.click(); } - function exportConfig() { + buttonHandlers.exportConfig=function() { let data = getAllConfigs(true); if (!data) return; downloadData(data, formatDateForFilename(true) + ".json"); } - function exportXdr() { + buttonHandlers.exportXdr=function() { let data = {}; forEl('.xdrvalue', function (el) { let name = el.getAttribute('name'); let value = el.value; - let err = checkXDR(value, data); + let err = checkers.checkXDR(value, data); if (err) { alert("error in " + name + ": " + value + "\n" + err); return; @@ -1133,10 +1135,10 @@ }); ip.click(); } - function importXdr() { + buttonHandlers.importXdr=function() { importJson(new RegExp(/^XDR[0-9][0-9]*/)); } - function importConfig() { + buttonHandlers.importConfig=function() { importJson(); } function toggleClass(el, id, classList) { @@ -1287,7 +1289,7 @@ } try{ Object.freeze(capabilities); - callListeners(api.EVENTS.capabilities,capabilities); + callListeners(api.EVENTS.init,capabilities); }catch (e){ console.log(e); } @@ -1325,7 +1327,7 @@ } - function adminPassCancel() { + buttonHandlers.adminPassCancel=function() { forEl('#adminPassOverlay', function (el) { el.classList.add('hidden') }); forEl('#adminPassInput', function (el) { el.value = '' }); } @@ -1342,7 +1344,7 @@ } catch (e) { } }); } - function forgetPass() { + buttonHandlers.forgetPass=function() { localStorage.removeItem('adminPass'); forEl('#adminPassInput', function (el) { el.value = ''; @@ -1395,7 +1397,7 @@ } }); } - function converterInfo() { + buttonHandlers.converterInfo=function() { getJson("api/converterInfo").then(function (json) { let text = "<h3>Converted entities</h3>"; text += "<p><b>NMEA0183 to NMEA2000:</b><br/>"; @@ -1740,7 +1742,7 @@ }); } } - function uploadBin(ev) { + buttonHandlers.uploadBin=function(ev) { let el = document.getElementById("uploadFile"); let progressEl = document.getElementById("uploadDone"); if (!el) return; @@ -1895,8 +1897,7 @@ forEl: forEl, closestParent: closestParent, EVENTS: { - init: 0, - capabilities: 1, //called when capabilities are loaded + init: 0, //called when capabilities are loaded, data is capabilities } }; function callListeners(event,data){ @@ -1906,7 +1907,7 @@ } }) } - window.esp32nmea = api; + window.esp32nmea2k = api; window.setInterval(update, 1000); window.setInterval(function () { let dp = document.getElementById('dashboardPage'); @@ -1919,10 +1920,10 @@ let buttons = document.querySelectorAll('button'); for (let i = 0; i < buttons.length; i++) { let be = buttons[i]; - let buttonFunction=eval(be.id); + let buttonFunction=buttonHandlers[be.id]; if (typeof(buttonFunction) === 'function'){ be.onclick = buttonFunction; //assume a function with the button id - console.log("button: "+be.id); + //console.log("button: "+be.id); } else{ console.log("no handler for button "+be.id); @@ -1983,6 +1984,6 @@ }) }) }) - callListeners(api.EVENTS.init); }); -}()); \ No newline at end of file +}()); + From e982389c75b661f14daf1e4233383435fbdddebb Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Sun, 13 Oct 2024 16:09:17 +0200 Subject: [PATCH 26/58] correctly handle data timeouts, always set source if for updates --- lib/boatData/GwBoatData.cpp | 3 +-- lib/boatData/GwBoatData.h | 5 ++--- lib/nmea0183ton2k/NMEA0183DataToN2K.cpp | 14 +++++++------- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/lib/boatData/GwBoatData.cpp b/lib/boatData/GwBoatData.cpp index f7be617..4be1450 100644 --- a/lib/boatData/GwBoatData.cpp +++ b/lib/boatData/GwBoatData.cpp @@ -47,7 +47,6 @@ GwBoatItemBase::GwBoatItemBase(String name, String format, GwBoatItemBase::TOTyp this->format = format; this->type = 0; this->lastUpdateSource = -1; - this->toType=TOType::user; } void GwBoatItemBase::setInvalidTime(unsigned long it, bool force){ if (toType != TOType::user || force ){ @@ -375,7 +374,7 @@ GwBoatItem<T> *GwBoatData::getOrCreate(T initial, GwBoatItemNameProvider *provid provider->getBoatItemFormat(), provider->getInvalidTime(), &values); - rt->update(initial); + rt->update(initial,-1); LOG_DEBUG(GwLog::LOG, "creating boatItem %s, type %d", name.c_str(), rt->getCurrentType()); return rt; diff --git a/lib/boatData/GwBoatData.h b/lib/boatData/GwBoatData.h index 1c4394c..18a5f08 100644 --- a/lib/boatData/GwBoatData.h +++ b/lib/boatData/GwBoatData.h @@ -105,8 +105,8 @@ template<class T> class GwBoatItem : public GwBoatItemBase{ GwBoatItem(String name,String formatInfo,unsigned long invalidTime=INVALID_TIME,GwBoatItemMap *map=NULL); GwBoatItem(String name,String formatInfo,TOType toType,GwBoatItemMap *map=NULL); virtual ~GwBoatItem(){} - bool update(T nv, int source=-1); - bool updateMax(T nv,int sourceId=-1); + bool update(T nv, int source); + bool updateMax(T nv,int sourceId); T getData(){ return data; } @@ -185,7 +185,6 @@ public: #define GWSPECBOATDATA(clazz,name,toType,fmt) \ clazz *name=new clazz(#name,GwBoatItemBase::fmt,toType,&values) ; class GwBoatData{ - static const unsigned long DEF_TIME=4000; private: GwLog *logger; GwBoatItemBase::GwBoatItemMap values; diff --git a/lib/nmea0183ton2k/NMEA0183DataToN2K.cpp b/lib/nmea0183ton2k/NMEA0183DataToN2K.cpp index 8bb9237..be01cac 100644 --- a/lib/nmea0183ton2k/NMEA0183DataToN2K.cpp +++ b/lib/nmea0183ton2k/NMEA0183DataToN2K.cpp @@ -103,7 +103,7 @@ private: if (v != NMEA0183UInt32NA){ return target->update(v,sourceId); } - return v; + return false; } uint32_t getUint32(GwBoatItem<uint32_t> *src){ return src->getDataWithDefault(N2kUInt32NA); @@ -406,13 +406,13 @@ private: case NMEA0183Wind_Apparent: shouldSend=updateDouble(boatData->AWA,WindAngle,msg.sourceId) && updateDouble(boatData->AWS,WindSpeed,msg.sourceId); - if (WindSpeed != NMEA0183DoubleNA) boatData->MaxAws->updateMax(WindSpeed); + if (WindSpeed != NMEA0183DoubleNA) boatData->MaxAws->updateMax(WindSpeed,msg.sourceId); mapping=config.findWindMapping(GwConverterConfig::WindMapping::AWA_AWS); break; case NMEA0183Wind_True: shouldSend=updateDouble(boatData->TWA,WindAngle,msg.sourceId) && updateDouble(boatData->TWS,WindSpeed,msg.sourceId); - if (WindSpeed != NMEA0183DoubleNA) boatData->MaxTws->updateMax(WindSpeed); + if (WindSpeed != NMEA0183DoubleNA) boatData->MaxTws->updateMax(WindSpeed,msg.sourceId); mapping=config.findWindMapping(GwConverterConfig::WindMapping::TWA_TWS); break; default: @@ -458,7 +458,7 @@ private: bool shouldSend = false; shouldSend = updateDouble(boatData->AWA, WindAngle, msg.sourceId) && updateDouble(boatData->AWS, WindSpeed, msg.sourceId); - if (WindSpeed != NMEA0183DoubleNA) boatData->MaxAws->updateMax(WindSpeed); + if (WindSpeed != NMEA0183DoubleNA) boatData->MaxAws->updateMax(WindSpeed,msg.sourceId); if (shouldSend) { const GwConverterConfig::WindMapping mapping=config.findWindMapping(GwConverterConfig::WindMapping::AWA_AWS); @@ -503,7 +503,7 @@ private: if (WindDirection != NMEA0183DoubleNA){ shouldSend = updateDouble(boatData->TWD, WindDirection, msg.sourceId) && updateDouble(boatData->TWS, WindSpeed, msg.sourceId); - if (WindSpeed != NMEA0183DoubleNA) boatData->MaxTws->updateMax(WindSpeed); + if (WindSpeed != NMEA0183DoubleNA) boatData->MaxTws->updateMax(WindSpeed,msg.sourceId); if(shouldSend && boatData->HDT->isValid()) { double twa = WindDirection-boatData->HDT->getData(); if(twa<0) { twa+=2*M_PI; } @@ -602,10 +602,10 @@ private: } //offset == 0? SK does not allow this if (Offset != NMEA0183DoubleNA && Offset>=0 ){ - if (! boatData->DBS->update(DepthBelowTransducer+Offset)) return; + if (! boatData->DBS->update(DepthBelowTransducer+Offset,msg.sourceId)) return; } if (Offset == NMEA0183DoubleNA) Offset=N2kDoubleNA; - if (! boatData->DBT->update(DepthBelowTransducer)) return; + if (! boatData->DBT->update(DepthBelowTransducer,msg.sourceId)) return; tN2kMsg n2kMsg; SetN2kWaterDepth(n2kMsg,1,DepthBelowTransducer,Offset); send(n2kMsg,msg.sourceId,String(n2kMsg.PGN)+String((Offset != N2kDoubleNA)?1:0)); From 9c979657bf157b497498f5ffd51d7b91c5e970c3 Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Sun, 13 Oct 2024 16:10:03 +0200 Subject: [PATCH 27/58] restructure boat data display handling, allow for more flexible user formatters --- lib/exampletask/index.js | 31 +++++- web/index.css | 5 +- web/index.html | 186 ++++++++++++++++++------------------ web/index.js | 199 ++++++++++++++++++++++++++++++++------- 4 files changed, 290 insertions(+), 131 deletions(-) diff --git a/lib/exampletask/index.js b/lib/exampletask/index.js index 0224a73..6c5322b 100644 --- a/lib/exampletask/index.js +++ b/lib/exampletask/index.js @@ -1,12 +1,39 @@ (function(){ + const api=window.esp32nmea2k; + if (! api) return; let isActive=false; - window.esp32nmea2k.registerListener((id,data)=>{ - if (id === 0){ + const tabName="example"; + const configName="exampleBDSel"; + let boatItemName; + api.registerListener((id,data)=>{ + if (id === api.EVENTS.init){ //data is capabilities if (data.testboard) isActive=true; + if (isActive){ + let page=api.addTabPage(tabName,"Example"); + api.addEl('div','',page,"this is a test tab"); + } } if (isActive){ console.log("exampletask listener",id,data); + if (id === api.EVENTS.tab){ + if (data === tabName){ + console.log("example tab activated"); + } + } + if (id == api.EVENTS.config){ + let nextboatItemName=data[configName]; + console.log("value of "+configName,nextboatItemName); + if (nextboatItemName){ + api.addUserFormatter(nextboatItemName,"xxx",function(v){ + return "X"+v+"X"; + }) + } + if (boatItemName !== undefined && boatItemName != nextboatItemName){ + api.removeUserFormatter(boatItemName); + } + boatItemName=nextboatItemName; + } } }) })(); diff --git a/web/index.css b/web/index.css index eb3b1ca..f248d97 100644 --- a/web/index.css +++ b/web/index.css @@ -22,7 +22,7 @@ body { overflow: hidden; } -.tabPage{ +#tabPages{ overflow: auto; } @@ -120,6 +120,9 @@ body { .hidden{ display: none !important; } + .dash.invalid{ + display: none; + } #xdrPage .row>.label{ display: none; } diff --git a/web/index.html b/web/index.html index e7d784d..0862868 100644 --- a/web/index.html +++ b/web/index.html @@ -23,104 +23,106 @@ <div class="tab" data-page="updatePage">Update</div> <div class="tab" data-url="https://github.com/wellenvogel/esp32-nmea2000" data-window="help" id="helpButton">Help</div> </div> -<div id="statusPage" class="tabPage"> - <div id="statusPageContent"> - <div class="row"> - <span class="label">VERSION</span> - <span class="value" id="version">---</span> - <button class="infoButton" id="converterInfo">?</button> - </div> +<div id="tabPages"> + <div id="statusPage" class="tabPage"> + <div id="statusPageContent"> + <div class="row"> + <span class="label">VERSION</span> + <span class="value" id="version">---</span> + <button class="infoButton" id="converterInfo">?</button> + </div> - <div class="row even"> - <span class="label">Access Point IP</span> - <span class="value" id="apIp">---</span> + <div class="row even"> + <span class="label">Access Point IP</span> + <span class="value" id="apIp">---</span> + </div> + <div class="row "> + <span class="label">wifi client connected</span> + <span class="value" id="wifiConnected">---</span> [<span class="value" id="wifiSSID">---</span>] + </div> + <div class="row even"> + <span class="label">wifi client IP</span> + <span class="value" id="clientIP">---</span> + </div> + <div class="row"> + <span class="label"># clients</span> + <span class="value" id="numClients">---</span> + </div> + <div class="row even"> + <span class="label">TCP client connected</span> + <span class="value" id="clientCon">---</span> + </div> + <div class="row"> + <span class="label">TCP client error</span> + <span class="value" id="clientErr">---</span> + </div> + <div class="row even"> + <span class="label">Free heap</span> + <span class="value" id="heap">---</span> + </div> + <div class="row"> + <span class="label">NMEA2000 State</span> + [<span class="value" id="n2knode">---</span>] + <span class="value" id="n2kstate">UNKNOWN</span> + </div> </div> - <div class="row "> - <span class="label">wifi client connected</span> - <span class="value" id="wifiConnected">---</span> [<span class="value" id="wifiSSID">---</span>] - </div> - <div class="row even"> - <span class="label">wifi client IP</span> - <span class="value" id="clientIP">---</span> - </div> - <div class="row"> - <span class="label"># clients</span> - <span class="value" id="numClients">---</span> - </div> - <div class="row even"> - <span class="label">TCP client connected</span> - <span class="value" id="clientCon">---</span> - </div> - <div class="row"> - <span class="label">TCP client error</span> - <span class="value" id="clientErr">---</span> - </div> - <div class="row even"> - <span class="label">Free heap</span> - <span class="value" id="heap">---</span> - </div> - <div class="row"> - <span class="label">NMEA2000 State</span> - [<span class="value" id="n2knode">---</span>] - <span class="value" id="n2kstate">UNKNOWN</span> - </div> + <button id="reset">Reset</button> </div> - <button id="reset">Reset</button> -</div> -<div class="configForm tabPage hidden" id="configPage" > - <div class="buttons"> - <button id="resetForm">ReloadConfig</button> - <button id="forgetPass">ForgetPass</button> - <button id="changeConfig">Save&Restart</button> - <button id="exportConfig">Export</button> - <button id="importConfig">Import</button> - <button id="factoryReset">FactoryReset</button> + <div class="configForm tabPage hidden" id="configPage"> + <div class="buttons"> + <button id="resetForm">ReloadConfig</button> + <button id="forgetPass">ForgetPass</button> + <button id="changeConfig">Save&Restart</button> + <button id="exportConfig">Export</button> + <button id="importConfig">Import</button> + <button id="factoryReset">FactoryReset</button> + </div> + <div class="configFormRows"> + + </div> </div> - <div class="configFormRows"> + <div class="configForm tabPage hidden" id="xdrPage"> + <div class="buttons"> + <button id="resetForm">ReloadConfig</button> + <button id="changeConfig">Save&Restart</button> + <button id="loadUnassigned">Show Unmapped</button> + <button id="exportXdr">Export</button> + <button id="importXdr">Import</button> + </div> + <div class="configFormRows"> + + </div> + </div> + <div class="tabPage hidden" id="dashboardPage"> </div> -</div> -<div class="configForm tabPage hidden" id="xdrPage" > - <div class="buttons"> - <button id="resetForm">ReloadConfig</button> - <button id="changeConfig">Save&Restart</button> - <button id="loadUnassigned">Show Unmapped</button> - <button id="exportXdr">Export</button> - <button id="importXdr">Import</button> - </div> - <div class="configFormRows"> - - </div> -</div> -<div class="tabPage hidden" id="dashboardPage"> - -</div> -<div class="tabPage hidden" id="updatePage"> - <div class="row"> - <span class="label">firmware type</span> - <span class="value status-fwtype">---</span> - </div> - <div class="row"> - <span class="label">chip type</span> - <span class="value status-chipid">---</span> - </div> - <div class="row"> - <span class="label">currentVersion</span> - <span class="value status-version">---</span> - </div> - <div class="row"> - <span class="label">New Firmware</span> - <input type="file" name="file1" id="uploadFile"> - </div> - <div class="row"> - <span class="label"></span> - <span id="imageProperties" class="value"></span> - </div> - <div id="uploadProgress"> - <div id="uploadDone"></div> - </div> - <div class="buttons"> - <button id="uploadBin">Upload</button> + <div class="tabPage hidden" id="updatePage"> + <div class="row"> + <span class="label">firmware type</span> + <span class="value status-fwtype">---</span> + </div> + <div class="row"> + <span class="label">chip type</span> + <span class="value status-chipid">---</span> + </div> + <div class="row"> + <span class="label">currentVersion</span> + <span class="value status-version">---</span> + </div> + <div class="row"> + <span class="label">New Firmware</span> + <input type="file" name="file1" id="uploadFile"> + </div> + <div class="row"> + <span class="label"></span> + <span id="imageProperties" class="value"></span> + </div> + <div id="uploadProgress"> + <div id="uploadDone"></div> + </div> + <div class="buttons"> + <button id="uploadBin">Upload</button> + </div> </div> </div> diff --git a/web/index.js b/web/index.js index 1d02096..c0d6ac2 100644 --- a/web/index.js +++ b/web/index.js @@ -8,6 +8,7 @@ let listeners = []; let buttonHandlers={}; let checkers={}; + let userFormatters={}; function addEl(type, clazz, parent, text) { let el = document.createElement(type); if (clazz) { @@ -19,7 +20,12 @@ }); } if (text) el.textContent = text; - if (parent) parent.appendChild(el); + if (parent) { + if (typeof(parent) != 'object'){ + parent=document.querySelector(parent); + } + if (parent) parent.appendChild(el); + } return el; } function forEl(query, callback, base) { @@ -122,6 +128,7 @@ function resetForm(ev) { getJson("/api/config") .then(function (jsonData) { + callListeners(api.EVENTS.config,jsonData); for (let k in jsonData) { if (k == "useAdminPass") { needAdminPass = jsonData[k] != 'false'; @@ -1418,16 +1425,15 @@ } let activeTab = document.getElementById(activeName); if (!activeTab) return; - let all = document.querySelectorAll('.tabPage'); - for (let i = 0; i < all.length; i++) { - all[i].classList.add('hidden'); - } - let tabs = document.querySelectorAll('.tab'); - for (let i = 0; i < all.length; i++) { - tabs[i].classList.remove('active'); - } + forEl('.tabPage',function(pel){ + pel.classList.add('hidden'); + }); + forEl('.tab',function(tel){ + tel.classList.remove('active'); + }); el.classList.add('active'); activeTab.classList.remove('hidden'); + callListeners(api.EVENTS.tab,activeName); } /** * @@ -1602,6 +1608,10 @@ } + } + Object.freeze(valueFormatters); + for (let k in valueFormatters){ + Object.freeze(valueFormatters[k]); } function resizeFont(el, reset, maxIt) { if (maxIt === undefined) maxIt = 10; @@ -1612,23 +1622,62 @@ el.style.fontSize = next + "px"; } } - function createDashboardItem(name, def, parent) { - if (!def.name) return; - let frame = addEl('div', 'dash', parent); - let title = addEl('span', 'dashTitle', frame, name); - let value = addEl('span', 'dashValue', frame); - value.setAttribute('id', 'data_' + name); - let fmt = valueFormatters[def.format]; - if (def.format) value.classList.add(def.format); - let footer = addEl('div', 'footer', frame); - let src = addEl('span', 'source', footer); - src.setAttribute('id', 'source_' + name); + function getUnit(def,useUser){ + let fmt = useUser?(userFormatters[def.name] || valueFormatters[def.format]):valueFormatters[def.format] ; let u = fmt ? fmt.u : ' '; if (!fmt && def.format && def.format.match(/formatXdr/)) { u = def.format.replace(/formatXdr:[^:]*:/, ''); } - addEl('span', 'unit', footer, u); - return value; + return u; + } + /** + * create a dashboard item if it does not exist + * @param {*} def + * @param {*} show + * @param {*} parent + * @returns the value div of the dashboard item + */ + function createOrHideDashboardItem(def,show, parent) { + if (!def.name) return; + let frame=document.getElementById('frame_'+def.name); + let build=false; + if (frame){ + if (frame.classList.contains('invalid') && show){ + build=true; + frame.classList.remove('invalid'); + frame.innerHTML=''; + } + } + else{ + if (! parent) return; + frame = addEl('div', 'dash', parent); + frame.setAttribute('id','frame_'+def.name); + build=true; + } + if (! show){ + if (!frame.classList.contains('invalid')){ + frame.classList.add('invalid'); + frame.innerHTML=''; + } + return; + } + if (build) { + let title = addEl('span', 'dashTitle', frame, def.name); + let value = addEl('span', 'dashValue', frame); + value.setAttribute('id', 'data_' + def.name); + if (def.format) value.classList.add(def.format); + let footer = addEl('div', 'footer', frame); + let src = addEl('span', 'source', footer); + src.setAttribute('id', 'source_' + def.name); + let u = getUnit(def, true) + addEl('span', 'unit', footer, u); + callListeners(api.EVENTS.dataItemCreated, frame); + } + let de = document.getElementById('data_' + def.name); + return de; + } + function hideDashboardItem(name){ + createOrHideDashboardItem({name:name},false); } function parseBoatDataLine(line) { let rt = {}; @@ -1659,6 +1708,7 @@ } let lastSelectList = []; function updateDashboard(data) { + callListeners(api.EVENTS.boatData,data); let frame = document.getElementById('dashboardPage'); let showInvalid = true; forEl('select[name=showInvalidData]', function (el) { @@ -1669,24 +1719,17 @@ let current = parseBoatDataLine(data[n]); if (!current.name) continue; names[current.name] = true; - let de = document.getElementById('data_' + current.name); - let isValid = current.valid; - if (!de && frame && (isValid || showInvalid)) { - de = createDashboardItem(current.name, current, frame); - } - if (de && (!isValid && !showInvalid)) { - de.parentElement.remove(); - continue; - } + let show = current.valid||showInvalid; + let de=createOrHideDashboardItem(current,show,frame); if (de) { let newContent = '----'; if (current.valid) { let formatter; if (current.format && current.format != "NULL") { let key = current.format.replace(/^\&/, ''); - formatter = valueFormatters[key]; + formatter = userFormatters[current.name]|| valueFormatters[key]; } - if (formatter) { + if (formatter && formatter.f) { newContent = formatter.f(current.value); } else { @@ -1711,11 +1754,14 @@ src.textContent = sourceName(current.source); } } - console.log("update"); - forEl('.dashValue', function (el) { + //console.log("update"); + //remove all items that are not send any more + //this can only happen if the device restarted + //otherwise data items will not go away - they will become invalid + forEl('.dash', function (el) { let id = el.getAttribute('id'); if (id) { - if (!names[id.replace(/^data_/, '')]) { + if (!names[id.replace(/^frame_/, '')]) { el.parentElement.remove(); } } @@ -1889,15 +1935,96 @@ reader.readAsArrayBuffer(slice); }); } + function addTabPage(name,label){ + if (label === undefined) label=name; + let tab=addEl('div','tab','#tabs',label); + tab.setAttribute('data-page',name); + tab.addEventListener('click',function(ev){ + handleTab(ev.target); + }) + let page=addEl('div','tabPage hidden','#tabPages'); + page.setAttribute('id',name); + return page; + } + function addUserFormatter(name,unit,formatter){ + if (unit !== undefined && formatter !== undefined){ + userFormatters[name]={ + u:unit, + f:formatter + } + } + else{ + delete userFormatters[name]; + } + hideDashboardItem(name); //will recreate it on next data receive + } const api= { registerListener: function (callback) { listeners.push(callback); }, + /** + * helper for creating dom elements + * parameters: + * type: the element type (e.g. div) + * class: a list of classes separated by space + * parent (opt): a parent element (either a dom element vor a query selector) + * text (opt): the text to be set as textContent + * returns: the newly created element + */ addEl: addEl, + /** + * iterator helper for a query selector + * parameters: + * query: the query selector + * callback: the callback function (will be called with the element as param) + * base (opt): a dome element to be used as the root (defaults to document) + */ forEl: forEl, + /** + * find the closest parent that has a particular class + * parameters: + * element: the element to start with + * class: the class to be searched for + * returns: the element or undefined/null + */ closestParent: closestParent, + /** + * add a new tab + * parameters: + * name - the name of the page + * label (opt): the label for the new page + * returns: the newly created element + */ + addTabPage: addTabPage, + /** + * add a user defined formatter for a boat data item + * parameters: + * name : the boat data item name + * unit: the unit to be displayed + * formatter: the formatter function (must return a string) + */ + addUserFormatter: addUserFormatter, + removeUserFormatter: function(name){ + addUserFormatter(name); + }, + /** + * a dict of formatters + * each one has 2 members: + * u: the unit + * f: the formatter function + */ + formatters: valueFormatters, + /** + * parse a line of boat data + * the line has name,format,valid,update,source,value + */ + parseBoatDataLine: parseBoatDataLine, EVENTS: { init: 0, //called when capabilities are loaded, data is capabilities + tab: 1, //tab page activated data is the id of the tab page + config: 2, //data is the config object + boatData: 3, //data is the list of boat Data items + dataItemCreated: 4, //data is the frame item of the boat data display } }; function callListeners(event,data){ From 47973b6bcf5d38332debafa006583b0d657b64d6 Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Sun, 13 Oct 2024 16:56:21 +0200 Subject: [PATCH 28/58] adjust user formatter api --- lib/exampletask/index.js | 49 +++++++++++++++++++++++++++++++++--- web/index.js | 54 ++++++++++++++++++++++++---------------- 2 files changed, 78 insertions(+), 25 deletions(-) diff --git a/lib/exampletask/index.js b/lib/exampletask/index.js index 6c5322b..eedd205 100644 --- a/lib/exampletask/index.js +++ b/lib/exampletask/index.js @@ -1,38 +1,81 @@ (function(){ const api=window.esp32nmea2k; if (! api) return; + //we only do something if a special capability is set + //on our case this is "testboard" + //so we only start any action when we receive the init event + //and we successfully checked that our requested capability is there let isActive=false; const tabName="example"; const configName="exampleBDSel"; let boatItemName; + let boatItemElement; api.registerListener((id,data)=>{ if (id === api.EVENTS.init){ //data is capabilities + //check if our requested capability is there (see GwExampleTask.h) if (data.testboard) isActive=true; if (isActive){ + //add a simple additional tab page + //you will have to build the content of the page dynamically + //using normal dom manipulation methods + //you can use the helper addEl to create elements let page=api.addTabPage(tabName,"Example"); api.addEl('div','',page,"this is a test tab"); } } if (isActive){ - console.log("exampletask listener",id,data); + //console.log("exampletask listener",id,data); if (id === api.EVENTS.tab){ if (data === tabName){ + //maybe we need some activity when our page is being activated console.log("example tab activated"); } } if (id == api.EVENTS.config){ + //we have a configuration that + //gives us the name of a boat data item we would like to + //handle special + //in our case we just use an own formatter and add some + //css to the display field + //as this item can change we need to keep track of the + //last item we handled let nextboatItemName=data[configName]; console.log("value of "+configName,nextboatItemName); if (nextboatItemName){ - api.addUserFormatter(nextboatItemName,"xxx",function(v){ - return "X"+v+"X"; + //register a user formatter that will be called whenever + //there is a new valid value + //we simply add an "X:" in front + api.addUserFormatter(nextboatItemName,"m(x)",function(v,valid){ + if (!valid) return; + return "X:"+v; }) + //after this call the item will be recreated } if (boatItemName !== undefined && boatItemName != nextboatItemName){ + //if the boat item that we handle has changed, remove + //the previous user formatter (this will recreate the item) api.removeUserFormatter(boatItemName); } boatItemName=nextboatItemName; + boatItemElement=undefined; + } + if (id == api.EVENTS.dataItemCreated){ + //this event is called whenever a data item has + //been created (or recreated) + //if this is the item we handle, we just add a css class + //we could also completely rebuild the dom below the element + //and use our formatter to directly write/draw the data + //avoid direct manipulation of the element (i.e. changing the classlist) + //as this element remains there all the time + if (boatItemName && boatItemName == data.name){ + boatItemElement=data.element; + //use the helper forEl to find elements within the dashboard item + //the value element has the class "dashValue" + api.forEl(".dashValue",function(el){ + el.classList.add("examplecss"); + },boatItemElement); + } } } }) diff --git a/web/index.js b/web/index.js index c0d6ac2..3329628 100644 --- a/web/index.js +++ b/web/index.js @@ -1671,7 +1671,7 @@ src.setAttribute('id', 'source_' + def.name); let u = getUnit(def, true) addEl('span', 'unit', footer, u); - callListeners(api.EVENTS.dataItemCreated, frame); + callListeners(api.EVENTS.dataItemCreated,{name:def.name,element:frame}); } let de = document.getElementById('data_' + def.name); return de; @@ -1720,30 +1720,39 @@ if (!current.name) continue; names[current.name] = true; let show = current.valid||showInvalid; - let de=createOrHideDashboardItem(current,show,frame); - if (de) { - let newContent = '----'; - if (current.valid) { - let formatter; - if (current.format && current.format != "NULL") { - let key = current.format.replace(/^\&/, ''); - formatter = userFormatters[current.name]|| valueFormatters[key]; - } - if (formatter && formatter.f) { - newContent = formatter.f(current.value); + let de = createOrHideDashboardItem(current, show, frame); + let newContent = '---'; + if (current.valid) { + let formatter; + if (current.format && current.format != "NULL") { + let key = current.format.replace(/^\&/, ''); + formatter = userFormatters[current.name] || valueFormatters[key]; + } + if (formatter && formatter.f) { + newContent = formatter.f(current.value,true); + if (newContent === undefined) newContent = ""; + } + else { + let v = parseFloat(current.value); + if (!isNaN(v)) { + v = v.toFixed(3) + newContent = v; } else { - let v = parseFloat(current.value); - if (!isNaN(v)) { - v = v.toFixed(3) - newContent = v; - } - else { - newContent = current.value; - } + newContent = current.value; } } - else newContent = "---"; + } + else { + let uf=userFormatters[current.name]; + if (uf && uf.f){ + //call the user formatter + //so that it can detect the invalid state + newContent=uf.f(undefined,false); + } + if (newContent === undefined)newContent = "---"; + } + if (de) { if (newContent !== de.textContent) { de.textContent = newContent; resizeFont(de, true); @@ -2024,7 +2033,8 @@ tab: 1, //tab page activated data is the id of the tab page config: 2, //data is the config object boatData: 3, //data is the list of boat Data items - dataItemCreated: 4, //data is the frame item of the boat data display + dataItemCreated: 4, //data is an object with + // name: the item name, element: the frame item of the boat data display } }; function callListeners(event,data){ From 795117b6f4c2189f8b80a0cd7757bfeef0b19afc Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Sun, 13 Oct 2024 17:12:10 +0200 Subject: [PATCH 29/58] allow to add an url tab button --- lib/exampletask/index.css | 3 +++ lib/exampletask/index.js | 8 +++++++- web/index.css | 1 + web/index.js | 8 ++++++-- 4 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 lib/exampletask/index.css diff --git a/lib/exampletask/index.css b/lib/exampletask/index.css new file mode 100644 index 0000000..b5b9328 --- /dev/null +++ b/lib/exampletask/index.css @@ -0,0 +1,3 @@ +.examplecss{ + background-color: coral; +} \ No newline at end of file diff --git a/lib/exampletask/index.js b/lib/exampletask/index.js index eedd205..a85fe10 100644 --- a/lib/exampletask/index.js +++ b/lib/exampletask/index.js @@ -8,6 +8,7 @@ let isActive=false; const tabName="example"; const configName="exampleBDSel"; + const infoUrl='https://github.com/wellenvogel/esp32-nmea2000/tree/master/lib/exampletask'; let boatItemName; let boatItemElement; api.registerListener((id,data)=>{ @@ -21,7 +22,12 @@ //using normal dom manipulation methods //you can use the helper addEl to create elements let page=api.addTabPage(tabName,"Example"); - api.addEl('div','',page,"this is a test tab"); + api.addEl('div','hdg',page,"this is a test tab"); + api.addEl('button','',page,'Info').addEventListener('click',function(ev){ + window.open(infoUrl,'info'); + }) + //add a tab for an external URL + api.addTabPage('exhelp','Info',infoUrl); } } if (isActive){ diff --git a/web/index.css b/web/index.css index f248d97..257b739 100644 --- a/web/index.css +++ b/web/index.css @@ -223,6 +223,7 @@ body { } #tabs { display: flex; + flex-wrap: wrap; border-bottom: 1px solid grey; margin-bottom: 0.5em; } diff --git a/web/index.js b/web/index.js index 3329628..337c21c 100644 --- a/web/index.js +++ b/web/index.js @@ -1944,13 +1944,17 @@ reader.readAsArrayBuffer(slice); }); } - function addTabPage(name,label){ + function addTabPage(name,label,url){ if (label === undefined) label=name; let tab=addEl('div','tab','#tabs',label); - tab.setAttribute('data-page',name); tab.addEventListener('click',function(ev){ handleTab(ev.target); }) + if (url !== undefined){ + tab.setAttribute('data-url',url); + return; + } + tab.setAttribute('data-page',name); let page=addEl('div','tabPage hidden','#tabPages'); page.setAttribute('id',name); return page; From 0ddc0d055db754d2113b3c39070edc6fddec601f Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Fri, 1 Nov 2024 11:57:53 +0100 Subject: [PATCH 30/58] add some doc for serial and USB ports --- Readme.md | 1 + doc/serial-usb.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 doc/serial-usb.md diff --git a/Readme.md b/Readme.md index 40f8d9b..6de6c6a 100644 --- a/Readme.md +++ b/Readme.md @@ -47,6 +47,7 @@ Hardware -------- The software is prepared to run on different kinds of ESP32 based modules and accessoirs. For some of them prebuild binaries are available that only need to be flashed, others would require to add some definitions of the used PINs and features and to build the binary. For the list of hardware set ups refer to [Hardware](doc/Hardware.md). +For details of the usage of serial devices and the USB connection refer to [Serial and USB](doc/serial-usb.md). There is a couple of prebuild binaries that you can directly flash to your device. For other combinations of hardware there is an [online build service](doc/BuildService.md) that will allow you to select your hardware and trigger a build. diff --git a/doc/serial-usb.md b/doc/serial-usb.md new file mode 100644 index 0000000..428e607 --- /dev/null +++ b/doc/serial-usb.md @@ -0,0 +1,51 @@ +Serial and USB +============== +The gateway software uses the [arduino layer](https://github.com/espressif/arduino-esp32) on top of the [espressif IDF framework](https://github.com/espressif/esp-idf). +The handling of serial devices is mainly done by the implementations in the arduino-espressif layer. +The gateway code adds some buffering on top of this implementation and ensures that normally only full nmea records are sent. +If the external connection is to slow the gateway will drop complete records. +All handling of the serial channels is done in the main task of the gateway. + +Serial Devices +-------------- +THe arduino espressif layer provides the serial devices as [Streams](https://github.com/espressif/arduino-esp32/blob/master/cores/esp32/Stream.h#L48). +Main implementations are [HardwareSerial](https://github.com/espressif/arduino-esp32/blob/3670e2bf2aca822f2e1225fdb0e0796e490005a8/cores/esp32/HardwareSerial.h#L71) - for the UARTS and [HWCDC](https://github.com/espressif/arduino-esp32/blob/3670e2bf2aca822f2e1225fdb0e0796e490005a8/cores/esp32/HWCDC.h#L43)(C3/S3 only) - for the USB CDC hardware device. + +For the github versions: arduino-espressif 3.20009 maps to github tag 2.0.9. + +The arduino espressif layer creates a couple of global instances for the serial devices (potentially depending on some defines). +The important defines for C3/S3 are: + + * ARDUINO_USB_MODE - the gateway always expects this to be 1 + * ARDUINO_USB_CDC_ON_BOOT - 0 or 1 (CB in the table below) + +The created devices for framework 3.20009: + +| Device(Variable) | Type(ESP32) | C3/S3 CB=0 | C3/S3 CB=1 | +| ------------ | ------- | ------ | -----| +| Serial0 | --- | --- | HardwareSerial(0) | +| Serial1 | HardwareSerial(1) | HardwareSerial(1) | HardwareSerial(1) | +| Serial2 | HardwareSerial(2) | HardwareSerial(2) | HardwareSerial(2) | +| USBSerial | --- | HWCDC | ---- | +| Serial | HardwareSerial(0) | HardwareSerial(0) | HWCDC | + +Unfortunately it seems that in newer versions of the framework the devices could change. + +The gateway will use the following serial devices: + +* USBserial:<br> + For debug output and NMEA as USB connection. If you do not use an S3/C3 with ARDUINO_USB_CDC_ON_BOOT=0 you need to add a <br>_define USBSerial Serial_ somewhere in your build flags or in your task header.<br> + Currently the gateway does not support setting the pins for the USB channel (that would be possible in principle only if an external PHY device is used and the USB is connected to a normal UART). +* Serial1:<br> + If you define GWSERIAL_TYPE (1,2,3,4) - see [defines](../lib/hardware/GwHardware.h#23) or GWSERIAL_MODE ("UNI","BI","TX","RX") it will be used for the first serial channel. +* Serial2:<br> + If you define GWSERIAL2_TYPE (1,2,3,4) - see [defines](../lib/hardware/GwHardware.h#23) or GWSERIAL2_MODE ("UNI","BI","TX","RX") it will be used for the second serial channel. + +Hints +----- +For normal ESP32 chips you need to set <br>_define USBSerial Serial_ <br>and you can use up to 2 serial channels beside the USB channel. +For C3/S3 chips you can go for 2 options: +1. set ARDUINO_USB_CDC_ON_BOOT=1: in This case you still need to set<br> _define USBSerial Serial_<br> You can use up to 2 serial channels in the gateway core - but you still have Serial0 available for a third channel (not yet supported by the core - but can be used in your user code) +2. set ARDUINO_USB_CDC_ON_BOOT=0: in this case USBSerial is already defined as the USB channel. You can use 2 channels in the gateway core and optional you can use Serial in your user code. + +If you do not set any of the GWSERIAL* defines (and they are not set by the core hardware definitions) you can freely use Serial1 and / or Serial2 in your user code. From 56ec7a04068303289b0544d2915e3489a849ac22 Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Fri, 1 Nov 2024 17:01:44 +0100 Subject: [PATCH 31/58] intermediate: restructure serial handling --- lib/channel/GwChannelList.cpp | 13 +++++++------ lib/channel/GwChannelList.h | 11 +++-------- lib/serial/GwSerial.cpp | 4 ++-- lib/serial/GwSerial.h | 22 ++++++++++++++++++++-- 4 files changed, 32 insertions(+), 18 deletions(-) diff --git a/lib/channel/GwChannelList.cpp b/lib/channel/GwChannelList.cpp index c736ae2..58b96b5 100644 --- a/lib/channel/GwChannelList.cpp +++ b/lib/channel/GwChannelList.cpp @@ -83,7 +83,7 @@ public: }; template<typename T> - class SerialWrapper : public GwChannelList::SerialWrapperBase{ + class SerialWrapper : public GwSerial::SerialWrapperBase{ private: template<class C> void beginImpl(C *s,unsigned long baud, uint32_t config=SERIAL_8N1, int8_t rxPin=-1, int8_t txPin=-1){} @@ -184,7 +184,7 @@ void GwChannelList::addSerial(int id, int rx, int tx, int type){ } LOG_DEBUG(GwLog::ERROR,"invalid serial config with id %d",id); } -void GwChannelList::addSerial(GwChannelList::SerialWrapperBase *stream,int type,int rx,int tx){ +void GwChannelList::addSerial(GwSerial::SerialWrapperBase *stream,int type,int rx,int tx){ const char *mode=nullptr; switch (type) { @@ -207,7 +207,7 @@ void GwChannelList::addSerial(GwChannelList::SerialWrapperBase *stream,int type, } addSerial(stream,mode,rx,tx); } -void GwChannelList::addSerial(GwChannelList::SerialWrapperBase *serialStream,const String &mode,int rx,int tx){ +void GwChannelList::addSerial(GwSerial::SerialWrapperBase *serialStream,const String &mode,int rx,int tx){ int id=serialStream->getId(); for (auto &&it:theChannels){ if (it->isOwnSource(id)){ @@ -251,7 +251,7 @@ void GwChannelList::addSerial(GwChannelList::SerialWrapperBase *serialStream,con LOG_DEBUG(GwLog::DEBUG,"serial set up: mode=%s,rx=%d,canRead=%d,tx=%d,canWrite=%d", mode.c_str(),rx,(int)canRead,tx,(int)canWrite); serialStream->begin(logger,config->getInt(param->baud,115200),SERIAL_8N1,rx,tx); - GwSerial *serial = new GwSerial(logger, serialStream->getStream(), id, canRead); + GwSerial *serial = new GwSerial(logger, serialStream, canRead); LOG_DEBUG(GwLog::LOG, "starting serial %d ", id); GwChannel *channel = new GwChannel(logger, param->name, id); channel->setImpl(serial); @@ -303,8 +303,9 @@ void GwChannelList::begin(bool fallbackSerial){ GwChannel *channel=NULL; //usb if (! fallbackSerial){ - GwSerial *usb=new GwSerial(NULL,&USBSerial,USB_CHANNEL_ID); - USBSerial.begin(config->getInt(config->usbBaud)); + GwSerial::SerialWrapperBase *usbWrapper=new SerialWrapper<decltype(USBSerial)>(&USBSerial,USB_CHANNEL_ID); + usbWrapper->begin(NULL,config->getInt(config->usbBaud)); + GwSerial *usb=new GwSerial(NULL,usbWrapper); logger->setWriter(new GwSerialLog(usb,config->getBool(config->usbActisense),getFlushTimeout(USBSerial))); logger->prefix="GWSERIAL:"; channel=new GwChannel(logger,"USB",USB_CHANNEL_ID); diff --git a/lib/channel/GwChannelList.h b/lib/channel/GwChannelList.h index 373dc6a..6ca20f8 100644 --- a/lib/channel/GwChannelList.h +++ b/lib/channel/GwChannelList.h @@ -8,6 +8,7 @@ #include "GWConfig.h" #include "GwJsonDocument.h" #include "GwApi.h" +#include "GwSerial.h" #include <HardwareSerial.h> //NMEA message channels @@ -23,12 +24,6 @@ class GwSocketServer; class GwTcpClient; class GwChannelList{ private: - class SerialWrapperBase{ - public: - virtual void begin(GwLog* logger,unsigned long baud, uint32_t config=SERIAL_8N1, int8_t rxPin=-1, int8_t txPin=-1)=0; - virtual Stream *getStream()=0; - virtual int getId()=0; - }; GwLog *logger; GwConfigHandler *config; typedef std::vector<GwChannel *> ChannelList; @@ -36,8 +31,8 @@ class GwChannelList{ std::map<int,String> modes; GwSocketServer *sockets; GwTcpClient *client; - void addSerial(SerialWrapperBase *stream,const String &mode,int rx,int tx); - void addSerial(SerialWrapperBase *stream,int type,int rx,int tx); + void addSerial(GwSerial::SerialWrapperBase *stream,const String &mode,int rx,int tx); + void addSerial(GwSerial::SerialWrapperBase *stream,int type,int rx,int tx); public: void addSerial(int id, int rx, int tx, int type); GwChannelList(GwLog *logger, GwConfigHandler *config); diff --git a/lib/serial/GwSerial.cpp b/lib/serial/GwSerial.cpp index c810e58..28c8d34 100644 --- a/lib/serial/GwSerial.cpp +++ b/lib/serial/GwSerial.cpp @@ -40,10 +40,10 @@ class GwSerialStream: public Stream{ -GwSerial::GwSerial(GwLog *logger, Stream *s, int id,bool allowRead):serial(s) +GwSerial::GwSerial(GwLog *logger, GwSerial::SerialWrapperBase *s, bool allowRead):serial(s) { LOG_DEBUG(GwLog::DEBUG,"creating GwSerial %p id %d",this,id); - this->id=id; + this->id=s->getId(); this->logger = logger; String bufName="Ser("; bufName+=String(id); diff --git a/lib/serial/GwSerial.h b/lib/serial/GwSerial.h index 5298bc6..f50149e 100644 --- a/lib/serial/GwSerial.h +++ b/lib/serial/GwSerial.h @@ -16,11 +16,27 @@ class GwSerial : public GwChannelInterface{ int id=-1; int overflows=0; size_t enqueue(const uint8_t *data, size_t len,bool partial=false); - Stream *serial; bool availableWrite=false; //if this is false we will wait for availabkleWrite until we flush again public: + class SerialWrapperBase{ + public: + virtual void begin(GwLog* logger,unsigned long baud, uint32_t config=SERIAL_8N1, int8_t rxPin=-1, int8_t txPin=-1)=0; + virtual int getId()=0; + virtual int available(){return getStream()->available();} + size_t readBytes(uint8_t *buffer, size_t length){ + return getStream()->readBytes(buffer,length); + } + virtual int availableForWrite(void){ + return getStream()->availableForWrite(); + } + size_t write(const uint8_t *buffer, size_t size){ + return getStream()->write(buffer,size); + } + private: + virtual Stream *getStream()=0; + }; static const int bufferSize=200; - GwSerial(GwLog *logger,Stream *stream,int id,bool allowRead=true); + GwSerial(GwLog *logger,SerialWrapperBase *stream,bool allowRead=true); ~GwSerial(); bool isInitialized(); virtual size_t sendToClients(const char *buf,int sourceId,bool partial=false); @@ -30,5 +46,7 @@ class GwSerial : public GwChannelInterface{ virtual Stream *getStream(bool partialWrites); bool getAvailableWrite(){return availableWrite;} friend GwSerialStream; + private: + SerialWrapperBase *serial; }; #endif \ No newline at end of file From c6f601377ca3babb437242779fc604a91041744c Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Sun, 3 Nov 2024 16:15:52 +0100 Subject: [PATCH 32/58] intermediate,untested: reorganize channel handling --- lib/channel/GwChannel.cpp | 17 +- lib/channel/GwChannel.h | 7 +- lib/channel/GwChannelInterface.h | 1 + lib/channel/GwChannelList.cpp | 325 ++++++++++++++----------------- lib/channel/GwChannelList.h | 5 +- lib/hardware/GwHardware.h | 1 + lib/serial/GwSerial.cpp | 38 +++- lib/serial/GwSerial.h | 79 +++++--- 8 files changed, 249 insertions(+), 224 deletions(-) diff --git a/lib/channel/GwChannel.cpp b/lib/channel/GwChannel.cpp index 93a2da3..069b5ae 100644 --- a/lib/channel/GwChannel.cpp +++ b/lib/channel/GwChannel.cpp @@ -218,14 +218,23 @@ void GwChannel::sendActisense(const tN2kMsg &msg, int sourceId){ if (!enabled || ! impl || ! writeActisense || ! channelStream) return; //currently actisense only for channels with a single source id //so we can check it here - if (isOwnSource(sourceId)) return; + if (maxSourceId < 0 && this->sourceId == sourceId) return; + if (sourceId >= this->sourceId && sourceId <= maxSourceId) return; countOut->add(String(msg.PGN)); msg.SendInActisenseFormat(channelStream); } -bool GwChannel::isOwnSource(int id){ - if (maxSourceId < 0) return id == sourceId; - else return (id >= sourceId && id <= maxSourceId); +bool GwChannel::overlaps(const GwChannel *other) const{ + if (maxSourceId < 0){ + if (other->maxSourceId < 0) return sourceId == other->sourceId; + return (other->sourceId <= sourceId && other->maxSourceId >= sourceId); + } + if (other->maxSourceId < 0){ + return other->sourceId >= sourceId && other->sourceId <= maxSourceId; + } + if (other->maxSourceId < sourceId) return false; + if (other->sourceId > maxSourceId) return false; + return true; } unsigned long GwChannel::countRx(){ diff --git a/lib/channel/GwChannel.h b/lib/channel/GwChannel.h index 77af597..951290b 100644 --- a/lib/channel/GwChannel.h +++ b/lib/channel/GwChannel.h @@ -50,7 +50,7 @@ class GwChannel{ ); void setImpl(GwChannelInterface *impl); - bool isOwnSource(int id); + bool overlaps(const GwChannel *) const; void enable(bool enabled){ this->enabled=enabled; } @@ -73,5 +73,10 @@ class GwChannel{ void sendActisense(const tN2kMsg &msg, int sourceId); unsigned long countRx(); unsigned long countTx(); + bool isOwnSource(int source){ + if (maxSourceId < 0) return source == sourceId; + return (source >= sourceId && source <= maxSourceId); + } + String getMode(){return impl->getMode();} }; diff --git a/lib/channel/GwChannelInterface.h b/lib/channel/GwChannelInterface.h index f9b076c..68f519b 100644 --- a/lib/channel/GwChannelInterface.h +++ b/lib/channel/GwChannelInterface.h @@ -6,4 +6,5 @@ class GwChannelInterface{ virtual void readMessages(GwMessageFetcher *writer)=0; virtual size_t sendToClients(const char *buffer, int sourceId, bool partial=false)=0; virtual Stream * getStream(bool partialWrites){ return NULL;} + virtual String getMode(){return "UNKNOWN";} }; \ No newline at end of file diff --git a/lib/channel/GwChannelList.cpp b/lib/channel/GwChannelList.cpp index 58b96b5..c09966c 100644 --- a/lib/channel/GwChannelList.cpp +++ b/lib/channel/GwChannelList.cpp @@ -18,16 +18,53 @@ class SerInit{ }; std::vector<SerInit> serialInits; +static int typeFromMode(const char *mode){ + if (strcmp(mode,"UNI") == 0) return GWSERIAL_TYPE_UNI; + if (strcmp(mode,"BI") == 0) return GWSERIAL_TYPE_BI; + if (strcmp(mode,"RX") == 0) return GWSERIAL_TYPE_RX; + if (strcmp(mode,"TX") == 0) return GWSERIAL_TYPE_TX; + return GWSERIAL_TYPE_UNK; +} + #define CFG_SERIAL(ser,...) \ __MSG("serial config " #ser); \ static GwInitializer<SerInit> __serial ## ser ## _init \ (serialInits,SerInit(ser,__VA_ARGS__)); #ifdef _GWI_SERIAL1 - CFG_SERIAL(1,_GWI_SERIAL1) + CFG_SERIAL(0,_GWI_SERIAL1) #endif #ifdef _GWI_SERIAL2 - CFG_SERIAL(2,_GWI_SERIAL2) + CFG_SERIAL(1,_GWI_SERIAL2) #endif +//handle separate defines + //serial 1 + #ifndef GWSERIAL_TX + #define GWSERIAL_TX -1 + #endif + #ifndef GWSERIAL_RX + #define GWSERIAL_RX -1 + #endif + #ifdef GWSERIAL_TYPE + CFG_SERIAL(0,GWSERIAL_RX,GWSERIAL_TX,GWSERIAL_TYPE) + #else + #ifdef GWSERIAL_MODE + CFG_SERIAL(0,GWSERIAL_RX,GWSERIAL_TX,typeFromMode(GWSERIAL_MODE)) + #endif + #endif + //serial 2 + #ifndef GWSERIAL2_TX + #define GWSERIAL2_TX -1 + #endif + #ifndef GWSERIAL2_RX + #define GWSERIAL2_RX -1 + #endif + #ifdef GWSERIAL2_TYPE + CFG_SERIAL(1,GWSERIAL2_RX,GWSERIAL2_TX,GWSERIAL2_TYPE) + #else + #ifdef GWSERIAL2_MODE + CFG_SERIAL(1,GWSERIAL2_RX,GWSERIAL2_TX,typeFromMode(GWSERIAL2_MODE)) + #endif + #endif class GwSerialLog : public GwLogWriter { static const size_t bufferSize = 4096; @@ -35,13 +72,11 @@ class GwSerialLog : public GwLogWriter int wp = 0; GwSerial *writer; bool disabled = false; - long flushTimeout=200; public: - GwSerialLog(GwSerial *writer, bool disabled,long flushTimeout=200) + GwSerialLog(GwSerial *writer, bool disabled) { this->writer = writer; this->disabled = disabled; - this->flushTimeout=flushTimeout; logBuffer = new char[bufferSize]; wp = 0; } @@ -64,7 +99,7 @@ public: { while (handled < wp) { - if ( !writer->flush(flushTimeout)) break; + if ( !writer->flush()) break; size_t rt = writer->sendToClients(logBuffer + handled, -1, true); handled += rt; } @@ -82,44 +117,6 @@ public: } }; -template<typename T> - class SerialWrapper : public GwSerial::SerialWrapperBase{ - private: - template<class C> - void beginImpl(C *s,unsigned long baud, uint32_t config=SERIAL_8N1, int8_t rxPin=-1, int8_t txPin=-1){} - void beginImpl(HardwareSerial *s,unsigned long baud, uint32_t config=SERIAL_8N1, int8_t rxPin=-1, int8_t txPin=-1){ - s->begin(baud,config,rxPin,txPin); - } - template<class C> - void setError(C* s, GwLog *logger){} - void setError(HardwareSerial *s,GwLog *logger){ - LOG_DEBUG(GwLog::LOG,"enable serial errors for channel %d",id); - s->onReceiveError([logger,this](hardwareSerial_error_t err){ - LOG_DEBUG(GwLog::ERROR,"serial error on id %d: %d",this->id,(int)err); - }); - } - #if CONFIG_IDF_TARGET_ESP32C3 || CONFIG_IDF_TARGET_ESP32S3 - void beginImpl(HWCDC *s,unsigned long baud, uint32_t config=SERIAL_8N1, int8_t rxPin=-1, int8_t txPin=-1){ - s->begin(baud); - } - #endif - T *serial; - int id; - public: - SerialWrapper(T* s,int i):serial(s),id(i){} - virtual void begin(GwLog* logger,unsigned long baud, uint32_t config=SERIAL_8N1, int8_t rxPin=-1, int8_t txPin=-1) override{ - beginImpl(serial,baud,config,rxPin,txPin); - setError(serial,logger); - }; - virtual Stream *getStream() override{ - return serial; - } - virtual int getId() override{ - return id; - } - - }; - GwChannelList::GwChannelList(GwLog *logger, GwConfigHandler *config){ this->logger=logger; @@ -139,10 +136,27 @@ typedef struct { const char *toN2K; const char *readF; const char *writeF; + const char *preventLog; + const char *readAct; + const char *writeAct; const char *name; } SerialParam; static SerialParam serialParameters[]={ + { + .id=USB_CHANNEL_ID, + .baud=GwConfigDefinitions::usbBaud, + .receive=GwConfigDefinitions::receiveUsb, + .send=GwConfigDefinitions::sendUsb, + .direction="", + .toN2K=GwConfigDefinitions::usbToN2k, + .readF=GwConfigDefinitions::usbReadFilter, + .writeF=GwConfigDefinitions::usbWriteFilter, + .preventLog=GwConfigDefinitions::usbActisense, + .readAct=GwConfigDefinitions::usbActisense, + .writeAct=GwConfigDefinitions::usbActSend, + .name="USB" + }, { .id=SERIAL1_CHANNEL_ID, .baud=GwConfigDefinitions::serialBaud, @@ -152,6 +166,9 @@ static SerialParam serialParameters[]={ .toN2K=GwConfigDefinitions::serialToN2k, .readF=GwConfigDefinitions::serialReadF, .writeF=GwConfigDefinitions::serialWriteF, + .preventLog="", + .readAct="", + .writeAct="", .name="Serial" }, { @@ -163,81 +180,38 @@ static SerialParam serialParameters[]={ .toN2K=GwConfigDefinitions::serial2ToN2k, .readF=GwConfigDefinitions::serial2ReadF, .writeF=GwConfigDefinitions::serial2WriteF, + .preventLog="", + .readAct="", + .writeAct="", .name="Serial2" } }; -static SerialParam *getSerialParam(int id){ - for (size_t idx=0;idx<sizeof(serialParameters)/sizeof(SerialParam*);idx++){ - if (serialParameters[idx].id == id) return &serialParameters[idx]; - } - return nullptr; -} -void GwChannelList::addSerial(int id, int rx, int tx, int type){ - if (id == 1){ - addSerial(new SerialWrapper<decltype(Serial1)>(&Serial1,SERIAL1_CHANNEL_ID),type,rx,tx); - return; - } - if (id == 2){ - addSerial(new SerialWrapper<decltype(Serial2)>(&Serial2,SERIAL2_CHANNEL_ID),type,rx,tx); - return; - } - LOG_DEBUG(GwLog::ERROR,"invalid serial config with id %d",id); -} -void GwChannelList::addSerial(GwSerial::SerialWrapperBase *stream,int type,int rx,int tx){ - const char *mode=nullptr; - switch (type) - { - case GWSERIAL_TYPE_UNI: - mode="UNI"; - break; - case GWSERIAL_TYPE_BI: - mode="BI"; - break; - case GWSERIAL_TYPE_RX: - mode="RX"; - break; - case GWSERIAL_TYPE_TX: - mode="TX"; - break; - } - if (mode == nullptr) { - LOG_DEBUG(GwLog::ERROR,"unknown serial type %d",type); - return; - } - addSerial(stream,mode,rx,tx); -} -void GwChannelList::addSerial(GwSerial::SerialWrapperBase *serialStream,const String &mode,int rx,int tx){ - int id=serialStream->getId(); - for (auto &&it:theChannels){ - if (it->isOwnSource(id)){ - LOG_DEBUG(GwLog::ERROR,"trying to re-add serial id=%d, ignoring",id); - return; - } - } - SerialParam *param=getSerialParam(id); - if (param == nullptr){ - logger->logDebug(GwLog::ERROR,"trying to set up an unknown serial channel: %d",id); - return; - } - if (rx < 0 && tx < 0){ - logger->logDebug(GwLog::ERROR,"useless config for serial %d: both rx/tx undefined"); - return; - } - modes[id]=String(mode); +template<typename T> +GwSerial* createSerial(GwLog *logger, T* s,int id, bool canRead=true){ + return new GwSerialImpl<T>(logger,s,id,canRead); +} + +static GwChannel * createSerialChannel(GwConfigHandler *config,GwLog *logger, int idx,int type,int rx,int tx, bool setLog=false){ + if (idx < 0 || idx >= sizeof(serialParameters)/sizeof(SerialParam*)) return nullptr; + SerialParam *param=&serialParameters[idx]; bool canRead=false; bool canWrite=false; - if (mode == "BI"){ + bool validType=false; + if (type == GWSERIAL_TYPE_BI){ canRead=config->getBool(param->receive); canWrite=config->getBool(param->send); + validType=true; } - if (mode == "TX"){ + if (type == GWSERIAL_TYPE_TX){ canWrite=true; + validType=true; } - if (mode == "RX"){ + if (type == GWSERIAL_TYPE_RX){ canRead=true; + validType=true; } - if (mode == "UNI"){ + if (type == GWSERIAL_TYPE_UNI ){ String cfgMode=config->getString(param->direction); if (cfgMode == "receive"){ canRead=true; @@ -245,16 +219,41 @@ void GwChannelList::addSerial(GwSerial::SerialWrapperBase *serialStream,const St if (cfgMode == "send"){ canWrite=true; } + validType=true; + } + if (! validType){ + LOG_DEBUG(GwLog::ERROR,"invalid type for serial channel %d: %d",param->id,type); + return nullptr; } if (rx < 0) canRead=false; if (tx < 0) canWrite=false; - LOG_DEBUG(GwLog::DEBUG,"serial set up: mode=%s,rx=%d,canRead=%d,tx=%d,canWrite=%d", - mode.c_str(),rx,(int)canRead,tx,(int)canWrite); - serialStream->begin(logger,config->getInt(param->baud,115200),SERIAL_8N1,rx,tx); - GwSerial *serial = new GwSerial(logger, serialStream, canRead); - LOG_DEBUG(GwLog::LOG, "starting serial %d ", id); - GwChannel *channel = new GwChannel(logger, param->name, id); - channel->setImpl(serial); + LOG_DEBUG(GwLog::DEBUG,"serial set up: type=%d,rx=%d,canRead=%d,tx=%d,canWrite=%d", + type,rx,(int)canRead,tx,(int)canWrite); + GwSerial *serialStream=nullptr; + GwLog *streamLog=setLog?nullptr:logger; + switch(param->id){ + case USB_CHANNEL_ID: + serialStream=createSerial(streamLog,&USBSerial,param->id); + break; + case SERIAL1_CHANNEL_ID: + serialStream=createSerial(streamLog,&Serial1,param->id); + break; + case SERIAL2_CHANNEL_ID: + serialStream=createSerial(streamLog,&Serial2,param->id); + break; + } + if (serialStream == nullptr){ + LOG_DEBUG(GwLog::ERROR,"invalid serial config with id %d",param->id); + return nullptr; + } + serialStream->begin(config->getInt(param->baud,115200),SERIAL_8N1,rx,tx); + if (setLog){ + logger->setWriter(new GwSerialLog(serialStream,config->getBool(param->preventLog,false))); + logger->prefix="GWSERIAL:"; + } + LOG_DEBUG(GwLog::LOG, "starting serial %d ", param->id); + GwChannel *channel = new GwChannel(logger, param->name,param->id); + channel->setImpl(serialStream); channel->begin( canRead || canWrite, canWrite, @@ -263,9 +262,20 @@ void GwChannelList::addSerial(GwSerial::SerialWrapperBase *serialStream,const St config->getString(param->writeF), false, config->getBool(param->toN2K), - false, - false); - LOG_DEBUG(GwLog::LOG, "%s", channel->toString().c_str()); + config->getBool(param->readAct), + config->getBool(param->writeAct)); + return channel; +} +void GwChannelList::addChannel(GwChannel * channel){ + for (auto &&it:theChannels){ + if (it->overlaps(channel)){ + LOG_DEBUG(GwLog::ERROR,"trying to add channel with overlapping ids %s (%s), ignoring", + channel->toString().c_str(), + it->toString().c_str()); + return; + } + } + LOG_DEBUG(GwLog::LOG, "adding channel %s", channel->toString().c_str()); theChannels.push_back(channel); } void GwChannelList::preinit(){ @@ -290,38 +300,20 @@ void GwChannelList::preinit(){ } } } -template<typename S> -long getFlushTimeout(S &s){ - return 200; -} -template<> -long getFlushTimeout(HardwareSerial &s){ - return 2000; -} +#ifndef GWUSB_TX + #define GWUSB_TX -1 +#endif +#ifndef GWUSB_RX + #define GWUSB_RX -1 +#endif + void GwChannelList::begin(bool fallbackSerial){ LOG_DEBUG(GwLog::DEBUG,"GwChannelList::begin"); GwChannel *channel=NULL; //usb if (! fallbackSerial){ - GwSerial::SerialWrapperBase *usbWrapper=new SerialWrapper<decltype(USBSerial)>(&USBSerial,USB_CHANNEL_ID); - usbWrapper->begin(NULL,config->getInt(config->usbBaud)); - GwSerial *usb=new GwSerial(NULL,usbWrapper); - logger->setWriter(new GwSerialLog(usb,config->getBool(config->usbActisense),getFlushTimeout(USBSerial))); - logger->prefix="GWSERIAL:"; - channel=new GwChannel(logger,"USB",USB_CHANNEL_ID); - channel->setImpl(usb); - channel->begin(true, - config->getBool(config->sendUsb), - config->getBool(config->receiveUsb), - config->getString(config->usbReadFilter), - config->getString(config->usbWriteFilter), - false, - config->getBool(config->usbToN2k), - config->getBool(config->usbActisense), - config->getBool(config->usbActSend) - ); - theChannels.push_back(channel); - LOG_DEBUG(GwLog::LOG,"%s",channel->toString().c_str()); + GwChannel *usb=createSerialChannel(config, logger,0,GWSERIAL_TYPE_BI,GWUSB_RX,GWUSB_TX,true); + addChannel(usb); } //TCP server sockets=new GwSocketServer(config,logger,MIN_TCP_CHANNEL_ID); @@ -339,42 +331,13 @@ void GwChannelList::begin(bool fallbackSerial){ false, false ); - LOG_DEBUG(GwLog::LOG,"%s",channel->toString().c_str()); - theChannels.push_back(channel); + addChannel(channel); //new serial config handling for (auto &&init:serialInits){ - addSerial(init.serial,init.rx,init.tx,init.mode); + (logger,init.serial,init.rx,init.tx,init.mode); } - //handle separate defines - //serial 1 - #ifndef GWSERIAL_TX - #define GWSERIAL_TX -1 - #endif - #ifndef GWSERIAL_RX - #define GWSERIAL_RX -1 - #endif - #ifdef GWSERIAL_TYPE - addSerial(new SerialWrapper<decltype(Serial1)>(&Serial1,SERIAL1_CHANNEL_ID),GWSERIAL_TYPE,GWSERIAL_RX,GWSERIAL_TX); - #else - #ifdef GWSERIAL_MODE - addSerial(new SerialWrapper<decltype(Serial1)>(&Serial1,SERIAL1_CHANNEL_ID),GWSERIAL_MODE,GWSERIAL_RX,GWSERIAL_TX); - #endif - #endif - //serial 2 - #ifndef GWSERIAL2_TX - #define GWSERIAL2_TX -1 - #endif - #ifndef GWSERIAL2_RX - #define GWSERIAL2_RX -1 - #endif - #ifdef GWSERIAL2_TYPE - addSerial(new SerialWrapper<decltype(Serial2)>(&Serial2,SERIAL2_CHANNEL_ID),GWSERIAL2_TYPE,GWSERIAL2_RX,GWSERIAL2_TX); - #else - #ifdef GWSERIAL2_MODE - addSerial(new SerialWrapper<decltype(Serial2)>(&Serial2,SERIAL2_CHANNEL_ID),GWSERIAL2_MODE,GWSERIAL2_RX,GWSERIAL2_TX); - #endif - #endif + //tcp client bool tclEnabled=config->getBool(config->tclEnabled); channel=new GwChannel(logger,"TCPClient",TCP_CLIENT_CHANNEL_ID); @@ -398,13 +361,13 @@ void GwChannelList::begin(bool fallbackSerial){ false, false ); - theChannels.push_back(channel); - LOG_DEBUG(GwLog::LOG,"%s",channel->toString().c_str()); + addChannel(channel); logger->flush(); } String GwChannelList::getMode(int id){ - auto it=modes.find(id); - if (it != modes.end()) return it->second; + for (auto && c: theChannels){ + if (c->isOwnSource(id)) return c->getMode(); + } return "UNKNOWN"; } int GwChannelList::getJsonSize(){ @@ -429,8 +392,8 @@ void GwChannelList::toJson(GwJsonDocument &doc){ }); } GwChannel *GwChannelList::getChannelById(int sourceId){ - for (auto it=theChannels.begin();it != theChannels.end();it++){ - if ((*it)->isOwnSource(sourceId)) return *it; + for (auto && it: theChannels){ + if (it->isOwnSource(sourceId)) return it; } return NULL; } diff --git a/lib/channel/GwChannelList.h b/lib/channel/GwChannelList.h index 6ca20f8..ba45c66 100644 --- a/lib/channel/GwChannelList.h +++ b/lib/channel/GwChannelList.h @@ -28,13 +28,10 @@ class GwChannelList{ GwConfigHandler *config; typedef std::vector<GwChannel *> ChannelList; ChannelList theChannels; - std::map<int,String> modes; GwSocketServer *sockets; GwTcpClient *client; - void addSerial(GwSerial::SerialWrapperBase *stream,const String &mode,int rx,int tx); - void addSerial(GwSerial::SerialWrapperBase *stream,int type,int rx,int tx); public: - void addSerial(int id, int rx, int tx, int type); + void addChannel(GwChannel *); GwChannelList(GwLog *logger, GwConfigHandler *config); typedef std::function<void(GwChannel *)> ChannelAction; void allChannels(ChannelAction action); diff --git a/lib/hardware/GwHardware.h b/lib/hardware/GwHardware.h index 8306409..0b14ab3 100644 --- a/lib/hardware/GwHardware.h +++ b/lib/hardware/GwHardware.h @@ -24,6 +24,7 @@ #define GWSERIAL_TYPE_BI 2 #define GWSERIAL_TYPE_RX 3 #define GWSERIAL_TYPE_TX 4 +#define GWSERIAL_TYPE_UNK 0 #include <GwConfigItem.h> #include <HardwareSerial.h> #include "GwAppInfo.h" diff --git a/lib/serial/GwSerial.cpp b/lib/serial/GwSerial.cpp index 28c8d34..c0fb06e 100644 --- a/lib/serial/GwSerial.cpp +++ b/lib/serial/GwSerial.cpp @@ -1,4 +1,5 @@ #include "GwSerial.h" +#include "GwHardware.h" class GwSerialStream: public Stream{ private: @@ -40,11 +41,13 @@ class GwSerialStream: public Stream{ -GwSerial::GwSerial(GwLog *logger, GwSerial::SerialWrapperBase *s, bool allowRead):serial(s) +GwSerial::GwSerial(GwLog *logger, Stream * stream,int id,int type,bool allowRead) { LOG_DEBUG(GwLog::DEBUG,"creating GwSerial %p id %d",this,id); - this->id=s->getId(); this->logger = logger; + this->id=id; + this->stream=stream; + this->type=type; String bufName="Ser("; bufName+=String(id); bufName+=")"; @@ -62,6 +65,20 @@ GwSerial::~GwSerial() if (readBuffer) delete readBuffer; } +String GwSerial::getMode(){ + switch (type){ + case GWSERIAL_TYPE_UNI: + return "UNI"; + case GWSERIAL_TYPE_BI: + return "BI"; + case GWSERIAL_TYPE_RX: + return "RX"; + case GWSERIAL_TYPE_TX: + return "TX"; + } + return "UNKNOWN"; +} + bool GwSerial::isInitialized() { return initialized; } size_t GwSerial::enqueue(const uint8_t *data, size_t len, bool partial) { @@ -70,9 +87,9 @@ size_t GwSerial::enqueue(const uint8_t *data, size_t len, bool partial) } GwBuffer::WriteStatus GwSerial::write(){ if (! isInitialized()) return GwBuffer::ERROR; - size_t numWrite=serial->availableForWrite(); + size_t numWrite=availableForWrite(); size_t rt=buffer->fetchData(numWrite,[](uint8_t *buffer,size_t len, void *p){ - return ((GwSerial *)p)->serial->write(buffer,len); + return ((GwSerial *)p)->stream->write(buffer,len); },this); if (rt != 0){ LOG_DEBUG(GwLog::DEBUG+1,"Serial %d write %d",id,rt); @@ -93,11 +110,11 @@ void GwSerial::loop(bool handleRead,bool handleWrite){ write(); if (! isInitialized()) return; if (! handleRead) return; - size_t available=serial->available(); + size_t available=stream->available(); if (! available) return; if (allowRead){ size_t rd=readBuffer->fillData(available,[](uint8_t *buffer, size_t len, void *p)->size_t{ - return ((GwSerial *)p)->serial->readBytes(buffer,len); + return ((GwSerial *)p)->stream->readBytes(buffer,len); },this); if (rd != 0){ LOG_DEBUG(GwLog::DEBUG+2,"GwSerial %d read %d bytes",id,rd); @@ -106,7 +123,7 @@ void GwSerial::loop(bool handleRead,bool handleWrite){ else{ uint8_t buffer[10]; if (available > 10) available=10; - serial->readBytes(buffer,available); + stream->readBytes(buffer,available); } } void GwSerial::readMessages(GwMessageFetcher *writer){ @@ -115,10 +132,11 @@ void GwSerial::readMessages(GwMessageFetcher *writer){ writer->handleBuffer(readBuffer); } -bool GwSerial::flush(long max){ +bool GwSerial::flush(){ if (! isInitialized()) return false; + long max=getFlushTimeout(); if (! availableWrite) { - if ( serial->availableForWrite() < 1){ + if ( availableForWrite() < 1){ return false; } availableWrite=true; @@ -128,7 +146,7 @@ bool GwSerial::flush(long max){ if (write() != GwBuffer::AGAIN) return true; vTaskDelay(1); } - availableWrite=(serial->availableForWrite() > 0); + availableWrite=(availableForWrite() > 0); return false; } Stream * GwSerial::getStream(bool partialWrite){ diff --git a/lib/serial/GwSerial.h b/lib/serial/GwSerial.h index f50149e..8e37974 100644 --- a/lib/serial/GwSerial.h +++ b/lib/serial/GwSerial.h @@ -6,10 +6,11 @@ #include "GwChannelInterface.h" class GwSerialStream; class GwSerial : public GwChannelInterface{ - private: + protected: GwBuffer *buffer; GwBuffer *readBuffer=NULL; GwLog *logger; + Stream *stream; bool initialized=false; bool allowRead=true; GwBuffer::WriteStatus write(); @@ -17,36 +18,66 @@ class GwSerial : public GwChannelInterface{ int overflows=0; size_t enqueue(const uint8_t *data, size_t len,bool partial=false); bool availableWrite=false; //if this is false we will wait for availabkleWrite until we flush again + virtual long getFlushTimeout(){return 2000;} + virtual int availableForWrite()=0; + int type=0; public: - class SerialWrapperBase{ - public: - virtual void begin(GwLog* logger,unsigned long baud, uint32_t config=SERIAL_8N1, int8_t rxPin=-1, int8_t txPin=-1)=0; - virtual int getId()=0; - virtual int available(){return getStream()->available();} - size_t readBytes(uint8_t *buffer, size_t length){ - return getStream()->readBytes(buffer,length); - } - virtual int availableForWrite(void){ - return getStream()->availableForWrite(); - } - size_t write(const uint8_t *buffer, size_t size){ - return getStream()->write(buffer,size); - } - private: - virtual Stream *getStream()=0; - }; - static const int bufferSize=200; - GwSerial(GwLog *logger,SerialWrapperBase *stream,bool allowRead=true); - ~GwSerial(); + GwSerial(GwLog *logger,Stream *stream,int id,int type,bool allowRead=true); + virtual ~GwSerial(); bool isInitialized(); virtual size_t sendToClients(const char *buf,int sourceId,bool partial=false); virtual void loop(bool handleRead=true,bool handleWrite=true); virtual void readMessages(GwMessageFetcher *writer); - bool flush(long millis=200); + bool flush(); virtual Stream *getStream(bool partialWrites); bool getAvailableWrite(){return availableWrite;} + virtual void begin(unsigned long baud, uint32_t config=SERIAL_8N1, int8_t rxPin=-1, int8_t txPin=-1)=0; + virtual String getMode() override; friend GwSerialStream; - private: - SerialWrapperBase *serial; }; + +template<typename T> + class GwSerialImpl : public GwSerial{ + private: + template<class C> + void beginImpl(C *s,unsigned long baud, uint32_t config=SERIAL_8N1, int8_t rxPin=-1, int8_t txPin=-1){} + void beginImpl(HardwareSerial *s,unsigned long baud, uint32_t config=SERIAL_8N1, int8_t rxPin=-1, int8_t txPin=-1){ + s->begin(baud,config,rxPin,txPin); + } + template<class C> + void setError(C* s, GwLog *logger){} + void setError(HardwareSerial *s,GwLog *logger){ + LOG_DEBUG(GwLog::LOG,"enable serial errors for channel %d",id); + s->onReceiveError([logger,this](hardwareSerial_error_t err){ + LOG_DEBUG(GwLog::ERROR,"serial error on id %d: %d",this->id,(int)err); + }); + } + #if CONFIG_IDF_TARGET_ESP32C3 || CONFIG_IDF_TARGET_ESP32S3 + void beginImpl(HWCDC *s,unsigned long baud, uint32_t config=SERIAL_8N1, int8_t rxPin=-1, int8_t txPin=-1){ + s->begin(baud); + } + #endif + template<class C> + long getFlushTimeoutImpl(const C*){return 2000;} + long getFlushTimeoutImpl(HWCDC *){return 200;} + + T *serial; + protected: + virtual long getFlushTimeout() override{ + return getFlushTimeoutImpl(serial); + } + virtual int availableForWrite(){ + return serial->availableForWrite(); + } + public: + GwSerialImpl(GwLog* logger,T* s,int i,int type,bool allowRead=true): GwSerial(logger,s,i,type,allowRead),serial(s){} + virtual ~GwSerialImpl(){} + virtual void begin(unsigned long baud, uint32_t config=SERIAL_8N1, int8_t rxPin=-1, int8_t txPin=-1) override{ + beginImpl(serial,baud,config,rxPin,txPin); + setError(serial,logger); + }; + + }; + + #endif \ No newline at end of file From 01d334d598f7a602352af2782745256edd8d21e2 Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Sun, 3 Nov 2024 17:33:30 +0100 Subject: [PATCH 33/58] only generate index.js, index.css on demand --- extra_script.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/extra_script.py b/extra_script.py index b8b5c97..ed62c64 100644 --- a/extra_script.py +++ b/extra_script.py @@ -375,14 +375,30 @@ def getLibs(): rt.append(e) return rt + + def joinFiles(target,pattern,dirlist): - with gzip.open(target,"wb") as oh: - for dir in dirlist: + flist=[] + for dir in dirlist: fn=os.path.join(dir,pattern) if os.path.exists(fn): - print("adding %s to %s"%(fn,target)) - with open(fn,"rb") as rh: - shutil.copyfileobj(rh,oh) + flist.append(fn) + current=False + if os.path.exists(target): + current=True + for f in flist: + if not isCurrent(f,target): + current=False + break + if current: + print("%s is up to date"%target) + return + print("creating %s"%target) + with gzip.open(target,"wb") as oh: + for fn in flist: + print("adding %s to %s"%(fn,target)) + with open(fn,"rb") as rh: + shutil.copyfileobj(rh,oh) OWNLIBS=getLibs()+["FS","WiFi"] From 40c4089f86cd8a575a83c8f8b5f116d75c3bf224 Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Sun, 3 Nov 2024 17:33:55 +0100 Subject: [PATCH 34/58] restructure channels, USB + serial1 working --- lib/channel/GwChannelList.cpp | 216 ++++++++++++++++++---------------- lib/log/GwLog.h | 2 + 2 files changed, 117 insertions(+), 101 deletions(-) diff --git a/lib/channel/GwChannelList.cpp b/lib/channel/GwChannelList.cpp index c09966c..072d95f 100644 --- a/lib/channel/GwChannelList.cpp +++ b/lib/channel/GwChannelList.cpp @@ -18,6 +18,7 @@ class SerInit{ }; std::vector<SerInit> serialInits; + static int typeFromMode(const char *mode){ if (strcmp(mode,"UNI") == 0) return GWSERIAL_TYPE_UNI; if (strcmp(mode,"BI") == 0) return GWSERIAL_TYPE_BI; @@ -31,90 +32,94 @@ static int typeFromMode(const char *mode){ static GwInitializer<SerInit> __serial ## ser ## _init \ (serialInits,SerInit(ser,__VA_ARGS__)); #ifdef _GWI_SERIAL1 - CFG_SERIAL(0,_GWI_SERIAL1) + CFG_SERIAL(SERIAL1_CHANNEL_ID,_GWI_SERIAL1) #endif #ifdef _GWI_SERIAL2 - CFG_SERIAL(1,_GWI_SERIAL2) + CFG_SERIAL(SERIAL2_CHANNEL_ID,_GWI_SERIAL2) #endif -//handle separate defines - //serial 1 - #ifndef GWSERIAL_TX - #define GWSERIAL_TX -1 - #endif - #ifndef GWSERIAL_RX - #define GWSERIAL_RX -1 - #endif - #ifdef GWSERIAL_TYPE - CFG_SERIAL(0,GWSERIAL_RX,GWSERIAL_TX,GWSERIAL_TYPE) - #else - #ifdef GWSERIAL_MODE - CFG_SERIAL(0,GWSERIAL_RX,GWSERIAL_TX,typeFromMode(GWSERIAL_MODE)) - #endif - #endif - //serial 2 - #ifndef GWSERIAL2_TX - #define GWSERIAL2_TX -1 - #endif - #ifndef GWSERIAL2_RX - #define GWSERIAL2_RX -1 - #endif - #ifdef GWSERIAL2_TYPE - CFG_SERIAL(1,GWSERIAL2_RX,GWSERIAL2_TX,GWSERIAL2_TYPE) - #else - #ifdef GWSERIAL2_MODE - CFG_SERIAL(1,GWSERIAL2_RX,GWSERIAL2_TX,typeFromMode(GWSERIAL2_MODE)) - #endif - #endif -class GwSerialLog : public GwLogWriter -{ - static const size_t bufferSize = 4096; - char *logBuffer = NULL; - int wp = 0; - GwSerial *writer; - bool disabled = false; -public: - GwSerialLog(GwSerial *writer, bool disabled) + // handle separate defines + // serial 1 +#ifndef GWSERIAL_TX +#define GWSERIAL_TX -1 +#endif +#ifndef GWSERIAL_RX +#define GWSERIAL_RX -1 +#endif +#ifdef GWSERIAL_TYPE + CFG_SERIAL(SERIAL1_CHANNEL_ID, GWSERIAL_RX, GWSERIAL_TX, GWSERIAL_TYPE) +#else +#ifdef GWSERIAL_MODE +CFG_SERIAL(SERIAL1_CHANNEL_ID, GWSERIAL_RX, GWSERIAL_TX, typeFromMode(GWSERIAL_MODE)) +#endif +#endif + // serial 2 +#ifndef GWSERIAL2_TX +#define GWSERIAL2_TX -1 +#endif +#ifndef GWSERIAL2_RX +#define GWSERIAL2_RX -1 +#endif +#ifdef GWSERIAL2_TYPE + CFG_SERIAL(SERIAL2_CHANNEL_ID, GWSERIAL2_RX, GWSERIAL2_TX, GWSERIAL2_TYPE) +#else +#ifdef GWSERIAL2_MODE +CFG_SERIAL(SERIAL2_CHANNEL_ID, GWSERIAL2_RX, GWSERIAL2_TX, typeFromMode(GWSERIAL2_MODE)) +#endif +#endif + class GwSerialLog : public GwLogWriter { - this->writer = writer; - this->disabled = disabled; - logBuffer = new char[bufferSize]; - wp = 0; - } - virtual ~GwSerialLog() {} - virtual void write(const char *data) - { - if (disabled) - return; - int len = strlen(data); - if ((wp + len) >= (bufferSize - 1)) - return; - strncpy(logBuffer + wp, data, len); - wp += len; - logBuffer[wp] = 0; - } - virtual void flush() - { - size_t handled = 0; - if (!disabled) + static const size_t bufferSize = 4096; + char *logBuffer = NULL; + int wp = 0; + GwSerial *writer; + bool disabled = false; + + public: + GwSerialLog(GwSerial *writer, bool disabled) { - while (handled < wp) - { - if ( !writer->flush()) break; - size_t rt = writer->sendToClients(logBuffer + handled, -1, true); - handled += rt; - } - if (handled < wp){ - if (handled > 0){ - memmove(logBuffer,logBuffer+handled,wp-handled); - wp-=handled; - logBuffer[wp]=0; - } - return; - } + this->writer = writer; + this->disabled = disabled; + logBuffer = new char[bufferSize]; + wp = 0; + } + virtual ~GwSerialLog() {} + virtual void write(const char *data) + { + if (disabled) + return; + int len = strlen(data); + if ((wp + len) >= (bufferSize - 1)) + return; + strncpy(logBuffer + wp, data, len); + wp += len; + logBuffer[wp] = 0; + } + virtual void flush() + { + size_t handled = 0; + if (!disabled) + { + while (handled < wp) + { + if (!writer->flush()) + break; + size_t rt = writer->sendToClients(logBuffer + handled, -1, true); + handled += rt; + } + if (handled < wp) + { + if (handled > 0) + { + memmove(logBuffer, logBuffer + handled, wp - handled); + wp -= handled; + logBuffer[wp] = 0; + } + return; + } + } + wp = 0; + logBuffer[0] = 0; } - wp = 0; - logBuffer[0] = 0; - } }; @@ -192,9 +197,23 @@ GwSerial* createSerial(GwLog *logger, T* s,int id, bool canRead=true){ return new GwSerialImpl<T>(logger,s,id,canRead); } +static SerialParam * findSerialParam(int id){ + SerialParam *param=nullptr; + for (auto && p: serialParameters){ + if (id == p.id){ + param=&p; + break; + } + } + return param; +} + static GwChannel * createSerialChannel(GwConfigHandler *config,GwLog *logger, int idx,int type,int rx,int tx, bool setLog=false){ - if (idx < 0 || idx >= sizeof(serialParameters)/sizeof(SerialParam*)) return nullptr; - SerialParam *param=&serialParameters[idx]; + SerialParam *param=findSerialParam(idx); + if (param == nullptr){ + LOG_DEBUG(GwLog::ERROR,"invalid serial channel id %d",idx); + return nullptr; + } bool canRead=false; bool canWrite=false; bool validType=false; @@ -225,9 +244,8 @@ static GwChannel * createSerialChannel(GwConfigHandler *config,GwLog *logger, in LOG_DEBUG(GwLog::ERROR,"invalid type for serial channel %d: %d",param->id,type); return nullptr; } - if (rx < 0) canRead=false; - if (tx < 0) canWrite=false; - LOG_DEBUG(GwLog::DEBUG,"serial set up: type=%d,rx=%d,canRead=%d,tx=%d,canWrite=%d", + LOG_DEBUG(GwLog::DEBUG,"serial set up: channel=%d, type=%d,rx=%d,canRead=%d,tx=%d,canWrite=%d", + idx, type,rx,(int)canRead,tx,(int)canWrite); GwSerial *serialStream=nullptr; GwLog *streamLog=setLog?nullptr:logger; @@ -275,28 +293,20 @@ void GwChannelList::addChannel(GwChannel * channel){ return; } } - LOG_DEBUG(GwLog::LOG, "adding channel %s", channel->toString().c_str()); + LOG_INFO("adding channel %s", channel->toString().c_str()); theChannels.push_back(channel); } void GwChannelList::preinit(){ for (auto &&init:serialInits){ + LOG_INFO("serial config found for %d",init.serial); if (init.fixedBaud >= 0){ - switch(init.serial){ - case 1: - { - LOG_DEBUG(GwLog::DEBUG,"setting fixed baud %d for serial",init.fixedBaud); - config->setValue(GwConfigDefinitions::serialBaud,String(init.fixedBaud),GwConfigInterface::READONLY); - } - break; - case 2: - { - LOG_DEBUG(GwLog::DEBUG,"setting fixed baud %d for serial2",init.fixedBaud); - config->setValue(GwConfigDefinitions::serial2Baud,String(init.fixedBaud),GwConfigInterface::READONLY); - } - break; - default: - LOG_DEBUG(GwLog::ERROR,"invalid serial definition %d found",init.serial) + SerialParam *param=findSerialParam(init.serial); + if (! param){ + LOG_ERROR("invalid serial definition %d found",init.serial) + return; } + LOG_DEBUG(GwLog::DEBUG,"setting fixed baud %d for serial %d",init.fixedBaud,init.serial); + config->setValue(param->baud,String(init.fixedBaud),GwConfigInterface::READONLY); } } } @@ -312,7 +322,7 @@ void GwChannelList::begin(bool fallbackSerial){ GwChannel *channel=NULL; //usb if (! fallbackSerial){ - GwChannel *usb=createSerialChannel(config, logger,0,GWSERIAL_TYPE_BI,GWUSB_RX,GWUSB_TX,true); + GwChannel *usb=createSerialChannel(config, logger,USB_CHANNEL_ID,GWSERIAL_TYPE_BI,GWUSB_RX,GWUSB_TX,true); addChannel(usb); } //TCP server @@ -335,7 +345,11 @@ void GwChannelList::begin(bool fallbackSerial){ //new serial config handling for (auto &&init:serialInits){ - (logger,init.serial,init.rx,init.tx,init.mode); + LOG_INFO("creating serial channel %d, rx=%d,tx=%d,type=%d",init.serial,init.rx,init.tx,init.mode); + GwChannel *ser=createSerialChannel(config,logger,init.serial,init.mode,init.rx,init.tx); + if (ser != nullptr){ + addChannel(ser); + } } //tcp client diff --git a/lib/log/GwLog.h b/lib/log/GwLog.h index 4958760..bdaa963 100644 --- a/lib/log/GwLog.h +++ b/lib/log/GwLog.h @@ -38,5 +38,7 @@ class GwLog{ long long getRecordCounter(){return recordCounter;} }; #define LOG_DEBUG(level,...){ if (logger != NULL && logger->isActive(level)) logger->logDebug(level,__VA_ARGS__);} +#define LOG_INFO(...){ if (logger != NULL && logger->isActive(GwLog::LOG)) logger->logDebug(GwLog::LOG,__VA_ARGS__);} +#define LOG_ERROR(...){ if (logger != NULL && logger->isActive(GwLog::ERROR)) logger->logDebug(GwLog::ERROR,__VA_ARGS__);} #endif \ No newline at end of file From 18b9946b62f8006d5cb482e7bf37cf1a42030920 Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Sun, 3 Nov 2024 18:05:14 +0100 Subject: [PATCH 35/58] intermediate: try special handling for USBCDC --- lib/serial/GwSerial.h | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/lib/serial/GwSerial.h b/lib/serial/GwSerial.h index 8e37974..75f73a8 100644 --- a/lib/serial/GwSerial.h +++ b/lib/serial/GwSerial.h @@ -4,6 +4,7 @@ #include "GwLog.h" #include "GwBuffer.h" #include "GwChannelInterface.h" +#include "hal/usb_serial_jtag_ll.h" class GwSerialStream; class GwSerial : public GwChannelInterface{ protected: @@ -39,6 +40,7 @@ class GwSerial : public GwChannelInterface{ template<typename T> class GwSerialImpl : public GwSerial{ private: + unsigned long lastWritable=0; template<class C> void beginImpl(C *s,unsigned long baud, uint32_t config=SERIAL_8N1, int8_t rxPin=-1, int8_t txPin=-1){} void beginImpl(HardwareSerial *s,unsigned long baud, uint32_t config=SERIAL_8N1, int8_t rxPin=-1, int8_t txPin=-1){ @@ -59,7 +61,29 @@ template<typename T> #endif template<class C> long getFlushTimeoutImpl(const C*){return 2000;} - long getFlushTimeoutImpl(HWCDC *){return 200;} + #if CONFIG_IDF_TARGET_ESP32C3 || CONFIG_IDF_TARGET_ESP32S3 + long getFlushTimeoutImpl(HWCDC *){return 200;} + #endif + + template<class C> + int availableForWrite(C* c){ + return c->availableForWrite(); + } + + #if CONFIG_IDF_TARGET_ESP32C3 || CONFIG_IDF_TARGET_ESP32S3 + int availableForWrite(HWCDC* c){ + int rt=c->availableForWrite(); + if (rt > 0) { + lastWritable=millis(); + return rt; + } + if (usb_serial_jtag_ll_txfifo_writable() == 1){ + LOG_INFO("USBserial restart"); + usb_serial_jtag_ll_ena_intr_mask(USB_SERIAL_JTAG_INTR_SERIAL_IN_EMPTY); + } + return rt; + } + #endif T *serial; protected: @@ -67,7 +91,7 @@ template<typename T> return getFlushTimeoutImpl(serial); } virtual int availableForWrite(){ - return serial->availableForWrite(); + return availableForWrite(serial); } public: GwSerialImpl(GwLog* logger,T* s,int i,int type,bool allowRead=true): GwSerial(logger,s,i,type,allowRead),serial(s){} From e3bab58f7e425d38bbffc0bf97c28b7107e6475d Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Mon, 4 Nov 2024 10:00:05 +0100 Subject: [PATCH 36/58] update to platform 6.8.1 --- platformio.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/platformio.ini b/platformio.ini index 2ed6e04..00c9321 100644 --- a/platformio.ini +++ b/platformio.ini @@ -30,10 +30,10 @@ lib_deps = Update [env] -platform = espressif32 @ 6.3.2 +platform = espressif32 @ 6.8.1 framework = arduino ;platform_packages= -; framework-arduinoespressif32 @ 3.20011.230801 +; framework-arduinoespressif32 @ 3.20017.0 ; framework-espidf @ 3.50101.0 lib_deps = ${basedeps.lib_deps} From 041b550ae9724088fde48698c075f869307fbca4 Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Mon, 4 Nov 2024 10:00:49 +0100 Subject: [PATCH 37/58] #81: restart HWCDC interrupt after 100ms --- lib/serial/GwSerial.h | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/lib/serial/GwSerial.h b/lib/serial/GwSerial.h index 75f73a8..83d98a6 100644 --- a/lib/serial/GwSerial.h +++ b/lib/serial/GwSerial.h @@ -4,7 +4,8 @@ #include "GwLog.h" #include "GwBuffer.h" #include "GwChannelInterface.h" -#include "hal/usb_serial_jtag_ll.h" + +#define USBCDC_RESTART_TIME 100 class GwSerialStream; class GwSerial : public GwChannelInterface{ protected: @@ -71,15 +72,24 @@ template<typename T> } #if CONFIG_IDF_TARGET_ESP32C3 || CONFIG_IDF_TARGET_ESP32S3 + /** + * issue #81 + * workaround for the HWCDC beeing stuck at some point in time + * with availableForWrite == 0 but the ISR being disabled + * we simply give a small delay of 100ms for availableForWrite being 0 + * and afterwards call isConnected that seems to retrigger the ISR + */ int availableForWrite(HWCDC* c){ int rt=c->availableForWrite(); if (rt > 0) { lastWritable=millis(); return rt; } - if (usb_serial_jtag_ll_txfifo_writable() == 1){ - LOG_INFO("USBserial restart"); - usb_serial_jtag_ll_ena_intr_mask(USB_SERIAL_JTAG_INTR_SERIAL_IN_EMPTY); + unsigned long now=millis(); + if (now > (lastWritable+USBCDC_RESTART_TIME)){ + lastWritable=now; + LOG_ERROR("***Restart USBCDC***"); + c->isConnected(); //this seems to retrigger the ISR } return rt; } From 1d5577a777cbd24ee5755a8ceb911a046d8ad50f Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Mon, 4 Nov 2024 10:32:52 +0100 Subject: [PATCH 38/58] #81: directly enable the ISR on HWCDC restart if connected --- lib/serial/GwSerial.h | 11 ++++++++--- tools/log.pl | 28 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) create mode 100755 tools/log.pl diff --git a/lib/serial/GwSerial.h b/lib/serial/GwSerial.h index 83d98a6..f3712ab 100644 --- a/lib/serial/GwSerial.h +++ b/lib/serial/GwSerial.h @@ -4,6 +4,9 @@ #include "GwLog.h" #include "GwBuffer.h" #include "GwChannelInterface.h" +#if CONFIG_IDF_TARGET_ESP32C3 || CONFIG_IDF_TARGET_ESP32S3 + #include "hal/usb_serial_jtag_ll.h" +#endif #define USBCDC_RESTART_TIME 100 class GwSerialStream; @@ -77,7 +80,7 @@ template<typename T> * workaround for the HWCDC beeing stuck at some point in time * with availableForWrite == 0 but the ISR being disabled * we simply give a small delay of 100ms for availableForWrite being 0 - * and afterwards call isConnected that seems to retrigger the ISR + * and afterwards retrigger the ISR */ int availableForWrite(HWCDC* c){ int rt=c->availableForWrite(); @@ -88,8 +91,10 @@ template<typename T> unsigned long now=millis(); if (now > (lastWritable+USBCDC_RESTART_TIME)){ lastWritable=now; - LOG_ERROR("***Restart USBCDC***"); - c->isConnected(); //this seems to retrigger the ISR + if (c->isConnected()){ + //this retriggers the ISR + usb_serial_jtag_ll_ena_intr_mask(USB_SERIAL_JTAG_INTR_SERIAL_IN_EMPTY); + } } return rt; } diff --git a/tools/log.pl b/tools/log.pl new file mode 100755 index 0000000..fcc5f5e --- /dev/null +++ b/tools/log.pl @@ -0,0 +1,28 @@ +#! /usr/bin/env perl +use strict; +use POSIX qw(strftime); +my ($dev,$speed)=@ARGV; +if (not defined $dev){ + die "usage: $0 dev" +} +if (! -e $dev) { + die "$dev not found" +} +open(my $fh,"<",$dev) or die "unable to open $dev"; +if (defined $speed){ + print("setting speed $speed"); + system("stty speed $speed < $dev") == 0 or die "unable to set speed"; +} +my $last=0; +while (<$fh>){ + my $x=time(); + if ($last != 0){ + if ($x > ($last+5)){ + print("****gap***\n"); + } + } + printf strftime("%Y/%m/%d-%H%M%S",localtime($x)); + printf("[%04.2f]: ",$x-$last); + $last=$x; + print $_; +} \ No newline at end of file From 5e592dee508c049e23add0b2979e43caa8002b0b Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Mon, 4 Nov 2024 10:50:17 +0100 Subject: [PATCH 39/58] set tty in raw mode for log tool --- tools/log.pl | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/log.pl b/tools/log.pl index fcc5f5e..86cb8e2 100755 --- a/tools/log.pl +++ b/tools/log.pl @@ -8,11 +8,12 @@ if (not defined $dev){ if (! -e $dev) { die "$dev not found" } -open(my $fh,"<",$dev) or die "unable to open $dev"; if (defined $speed){ print("setting speed $speed"); system("stty speed $speed < $dev") == 0 or die "unable to set speed"; } +system("stty raw < $dev") == 0 or die "unable to set raw mode for $dev"; +open(my $fh,"<",$dev) or die "unable to open $dev"; my $last=0; while (<$fh>){ my $x=time(); From d0dee367f8da43f0114190928711208737f0922f Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Mon, 4 Nov 2024 18:52:47 +0100 Subject: [PATCH 40/58] unify channel handling --- lib/channel/GwChannel.h | 1 + lib/channel/GwChannelList.cpp | 233 ++++++++++++++++++++-------------- 2 files changed, 138 insertions(+), 96 deletions(-) diff --git a/lib/channel/GwChannel.h b/lib/channel/GwChannel.h index 951290b..66fb4ae 100644 --- a/lib/channel/GwChannel.h +++ b/lib/channel/GwChannel.h @@ -78,5 +78,6 @@ class GwChannel{ return (source >= sourceId && source <= maxSourceId); } String getMode(){return impl->getMode();} + int getMinId(){return sourceId;}; }; diff --git a/lib/channel/GwChannelList.cpp b/lib/channel/GwChannelList.cpp index 072d95f..56f4598 100644 --- a/lib/channel/GwChannelList.cpp +++ b/lib/channel/GwChannelList.cpp @@ -144,10 +144,14 @@ typedef struct { const char *preventLog; const char *readAct; const char *writeAct; + const char *sendSeasmart; const char *name; -} SerialParam; + int maxId; + size_t rxstatus; + size_t txstatus; +} ChannelParam; -static SerialParam serialParameters[]={ +static ChannelParam channelParameters[]={ { .id=USB_CHANNEL_ID, .baud=GwConfigDefinitions::usbBaud, @@ -160,7 +164,11 @@ static SerialParam serialParameters[]={ .preventLog=GwConfigDefinitions::usbActisense, .readAct=GwConfigDefinitions::usbActisense, .writeAct=GwConfigDefinitions::usbActSend, - .name="USB" + .sendSeasmart="", + .name="USB", + .maxId=-1, + .rxstatus=offsetof(GwApi::Status,GwApi::Status::usbRx), + .txstatus=offsetof(GwApi::Status,GwApi::Status::usbTx) }, { .id=SERIAL1_CHANNEL_ID, @@ -174,7 +182,11 @@ static SerialParam serialParameters[]={ .preventLog="", .readAct="", .writeAct="", - .name="Serial" + .sendSeasmart="", + .name="Serial", + .maxId=-1, + .rxstatus=offsetof(GwApi::Status,GwApi::Status::serRx), + .txstatus=offsetof(GwApi::Status,GwApi::Status::serTx) }, { .id=SERIAL2_CHANNEL_ID, @@ -188,8 +200,49 @@ static SerialParam serialParameters[]={ .preventLog="", .readAct="", .writeAct="", - .name="Serial2" + .sendSeasmart="", + .name="Serial2", + .maxId=-1, + .rxstatus=offsetof(GwApi::Status,GwApi::Status::ser2Rx), + .txstatus=offsetof(GwApi::Status,GwApi::Status::ser2Tx) + }, + { + .id=MIN_TCP_CHANNEL_ID, + .baud="", + .receive=GwConfigDefinitions::readTCP, + .send=GwConfigDefinitions::sendTCP, + .direction="", + .toN2K=GwConfigDefinitions::tcpToN2k, + .readF=GwConfigDefinitions::tcpReadFilter, + .writeF=GwConfigDefinitions::tcpWriteFilter, + .preventLog="", + .readAct="", + .writeAct="", + .sendSeasmart=GwConfigDefinitions::sendSeasmart, + .name="TCPServer", + .maxId=MIN_TCP_CHANNEL_ID+10, + .rxstatus=offsetof(GwApi::Status,GwApi::Status::tcpSerRx), + .txstatus=offsetof(GwApi::Status,GwApi::Status::tcpSerTx) + }, + { + .id=TCP_CLIENT_CHANNEL_ID, + .baud="", + .receive=GwConfigDefinitions::readTCL, + .send=GwConfigDefinitions::sendTCL, + .direction="", + .toN2K=GwConfigDefinitions::tclToN2k, + .readF=GwConfigDefinitions::tclReadFilter, + .writeF=GwConfigDefinitions::tclWriteFilter, + .preventLog="", + .readAct="", + .writeAct="", + .sendSeasmart=GwConfigDefinitions::tclSeasmart, + .name="TCPClient", + .maxId=-1, + .rxstatus=offsetof(GwApi::Status,GwApi::Status::tcpClRx), + .txstatus=offsetof(GwApi::Status,GwApi::Status::tcpClTx) } + }; template<typename T> @@ -197,9 +250,9 @@ GwSerial* createSerial(GwLog *logger, T* s,int id, bool canRead=true){ return new GwSerialImpl<T>(logger,s,id,canRead); } -static SerialParam * findSerialParam(int id){ - SerialParam *param=nullptr; - for (auto && p: serialParameters){ +static ChannelParam * findChannelParam(int id){ + ChannelParam *param=nullptr; + for (auto && p: channelParameters){ if (id == p.id){ param=&p; break; @@ -208,12 +261,44 @@ static SerialParam * findSerialParam(int id){ return param; } -static GwChannel * createSerialChannel(GwConfigHandler *config,GwLog *logger, int idx,int type,int rx,int tx, bool setLog=false){ - SerialParam *param=findSerialParam(idx); +static GwSerial * createSerialImpl(GwConfigHandler *config,GwLog *logger, int idx,int rx,int tx, bool setLog=false){ + LOG_DEBUG(GwLog::DEBUG,"create serial: channel=%d, rx=%d,tx=%d", + idx,rx,tx); + ChannelParam *param=findChannelParam(idx); if (param == nullptr){ LOG_DEBUG(GwLog::ERROR,"invalid serial channel id %d",idx); return nullptr; } + GwSerial *serialStream=nullptr; + GwLog *streamLog=setLog?nullptr:logger; + switch(param->id){ + case USB_CHANNEL_ID: + serialStream=createSerial(streamLog,&USBSerial,param->id); + break; + case SERIAL1_CHANNEL_ID: + serialStream=createSerial(streamLog,&Serial1,param->id); + break; + case SERIAL2_CHANNEL_ID: + serialStream=createSerial(streamLog,&Serial2,param->id); + break; + } + if (serialStream == nullptr){ + LOG_DEBUG(GwLog::ERROR,"invalid serial config with id %d",param->id); + return nullptr; + } + serialStream->begin(config->getInt(param->baud,115200),SERIAL_8N1,rx,tx); + if (setLog){ + logger->setWriter(new GwSerialLog(serialStream,config->getBool(param->preventLog,false))); + logger->prefix="GWSERIAL:"; + } + return serialStream; +} +static GwChannel * createChannel(GwLog *logger, GwConfigHandler *config, int id,GwChannelInterface *impl, int type=GWSERIAL_TYPE_BI){ + ChannelParam *param=findChannelParam(id); + if (param == nullptr){ + LOG_DEBUG(GwLog::ERROR,"invalid channel id %d",id); + return nullptr; + } bool canRead=false; bool canWrite=false; bool validType=false; @@ -241,37 +326,11 @@ static GwChannel * createSerialChannel(GwConfigHandler *config,GwLog *logger, in validType=true; } if (! validType){ - LOG_DEBUG(GwLog::ERROR,"invalid type for serial channel %d: %d",param->id,type); + LOG_DEBUG(GwLog::ERROR,"invalid type for channel %d: %d",param->id,type); return nullptr; } - LOG_DEBUG(GwLog::DEBUG,"serial set up: channel=%d, type=%d,rx=%d,canRead=%d,tx=%d,canWrite=%d", - idx, - type,rx,(int)canRead,tx,(int)canWrite); - GwSerial *serialStream=nullptr; - GwLog *streamLog=setLog?nullptr:logger; - switch(param->id){ - case USB_CHANNEL_ID: - serialStream=createSerial(streamLog,&USBSerial,param->id); - break; - case SERIAL1_CHANNEL_ID: - serialStream=createSerial(streamLog,&Serial1,param->id); - break; - case SERIAL2_CHANNEL_ID: - serialStream=createSerial(streamLog,&Serial2,param->id); - break; - } - if (serialStream == nullptr){ - LOG_DEBUG(GwLog::ERROR,"invalid serial config with id %d",param->id); - return nullptr; - } - serialStream->begin(config->getInt(param->baud,115200),SERIAL_8N1,rx,tx); - if (setLog){ - logger->setWriter(new GwSerialLog(serialStream,config->getBool(param->preventLog,false))); - logger->prefix="GWSERIAL:"; - } - LOG_DEBUG(GwLog::LOG, "starting serial %d ", param->id); - GwChannel *channel = new GwChannel(logger, param->name,param->id); - channel->setImpl(serialStream); + GwChannel *channel = new GwChannel(logger, param->name,param->id,param->maxId); + channel->setImpl(impl); channel->begin( canRead || canWrite, canWrite, @@ -282,9 +341,11 @@ static GwChannel * createSerialChannel(GwConfigHandler *config,GwLog *logger, in config->getBool(param->toN2K), config->getBool(param->readAct), config->getBool(param->writeAct)); + LOG_INFO("created channel %s",channel->toString().c_str()); return channel; } void GwChannelList::addChannel(GwChannel * channel){ + if (channel == nullptr) return; for (auto &&it:theChannels){ if (it->overlaps(channel)){ LOG_DEBUG(GwLog::ERROR,"trying to add channel with overlapping ids %s (%s), ignoring", @@ -300,7 +361,7 @@ void GwChannelList::preinit(){ for (auto &&init:serialInits){ LOG_INFO("serial config found for %d",init.serial); if (init.fixedBaud >= 0){ - SerialParam *param=findSerialParam(init.serial); + ChannelParam *param=findChannelParam(init.serial); if (! param){ LOG_ERROR("invalid serial definition %d found",init.serial) return; @@ -322,39 +383,39 @@ void GwChannelList::begin(bool fallbackSerial){ GwChannel *channel=NULL; //usb if (! fallbackSerial){ - GwChannel *usb=createSerialChannel(config, logger,USB_CHANNEL_ID,GWSERIAL_TYPE_BI,GWUSB_RX,GWUSB_TX,true); - addChannel(usb); + GwSerial *usbSerial=createSerialImpl(config, logger,USB_CHANNEL_ID,GWUSB_RX,GWUSB_TX,true); + if (usbSerial != nullptr){ + GwChannel *usbChannel=createChannel(logger,config,USB_CHANNEL_ID,usbSerial,GWSERIAL_TYPE_BI); + if (usbChannel != nullptr){ + addChannel(usbChannel); + } + else{ + delete usbSerial; + } + } } //TCP server sockets=new GwSocketServer(config,logger,MIN_TCP_CHANNEL_ID); sockets->begin(); - channel=new GwChannel(logger,"TCPserver",MIN_TCP_CHANNEL_ID,MIN_TCP_CHANNEL_ID+10); - channel->setImpl(sockets); - channel->begin( - true, - config->getBool(config->sendTCP), - config->getBool(config->readTCP), - config->getString(config->tcpReadFilter), - config->getString(config->tcpWriteFilter), - config->getBool(config->sendSeasmart), - config->getBool(config->tcpToN2k), - false, - false - ); - addChannel(channel); + addChannel(createChannel(logger,config,MIN_TCP_CHANNEL_ID,sockets)); //new serial config handling for (auto &&init:serialInits){ LOG_INFO("creating serial channel %d, rx=%d,tx=%d,type=%d",init.serial,init.rx,init.tx,init.mode); - GwChannel *ser=createSerialChannel(config,logger,init.serial,init.mode,init.rx,init.tx); + GwSerial *ser=createSerialImpl(config,logger,init.serial,init.rx,init.tx); if (ser != nullptr){ - addChannel(ser); + channel=createChannel(logger,config,init.serial,ser,init.mode); + if (channel != nullptr){ + addChannel(channel); + } + else{ + delete ser; + } } } //tcp client bool tclEnabled=config->getBool(config->tclEnabled); - channel=new GwChannel(logger,"TCPClient",TCP_CLIENT_CHANNEL_ID); if (tclEnabled){ client=new GwTcpClient(logger); client->begin(TCP_CLIENT_CHANNEL_ID, @@ -362,20 +423,8 @@ void GwChannelList::begin(bool fallbackSerial){ config->getInt(config->remotePort), config->getBool(config->readTCL) ); - channel->setImpl(client); } - channel->begin( - tclEnabled, - config->getBool(config->sendTCL), - config->getBool(config->readTCL), - config->getString(config->tclReadFilter), - config->getString(config->tclReadFilter), - config->getBool(config->tclSeasmart), - config->getBool(config->tclToN2k), - false, - false - ); - addChannel(channel); + addChannel(createChannel(logger,config,TCP_CLIENT_CHANNEL_ID,client)); logger->flush(); } String GwChannelList::getMode(int id){ @@ -412,30 +461,22 @@ GwChannel *GwChannelList::getChannelById(int sourceId){ return NULL; } +/** + * slightly tricky generic setter for the API status + * we expect all values to be unsigned long + * the offsets are always offsetof(GwApi::Status,GwApi::Status::xxx) +*/ +static void setStatus(GwApi::Status *status,size_t offset,unsigned long v){ + if (offset == 0) return; + *((unsigned long *)(((unsigned char *)status)+offset))=v; +} + void GwChannelList::fillStatus(GwApi::Status &status){ - GwChannel *channel=getChannelById(USB_CHANNEL_ID); - if (channel){ - status.usbRx=channel->countRx(); - status.usbTx=channel->countTx(); - } - channel=getChannelById(SERIAL1_CHANNEL_ID); - if (channel){ - status.serRx=channel->countRx(); - status.serTx=channel->countTx(); - } - channel=getChannelById(SERIAL2_CHANNEL_ID); - if (channel){ - status.ser2Rx=channel->countRx(); - status.ser2Tx=channel->countTx(); - } - channel=getChannelById(MIN_TCP_CHANNEL_ID); - if (channel){ - status.tcpSerRx=channel->countRx(); - status.tcpSerTx=channel->countTx(); - } - channel=getChannelById(TCP_CLIENT_CHANNEL_ID); - if (channel){ - status.tcpClRx=channel->countRx(); - status.tcpClTx=channel->countTx(); + for (auto && channel: theChannels){ + ChannelParam *param=findChannelParam(channel->getMinId()); + if (param != nullptr){ + setStatus(&status,param->rxstatus,channel->countRx()); + setStatus(&status,param->txstatus,channel->countTx()); + } } } \ No newline at end of file From 490a5b9ba1781e9ea4fef40f6e584eac1b62833d Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Mon, 4 Nov 2024 20:21:33 +0100 Subject: [PATCH 41/58] #79: add udp writer --- lib/api/GwApi.h | 1 + lib/channel/GwChannelList.cpp | 26 ++++++++++++++ lib/channel/GwChannelList.h | 1 + lib/socketserver/GwUdpWriter.cpp | 59 ++++++++++++++++++++++++++++++++ lib/socketserver/GwUdpWriter.h | 28 +++++++++++++++ web/config.json | 41 ++++++++++++++++++++++ 6 files changed, 156 insertions(+) create mode 100644 lib/socketserver/GwUdpWriter.cpp create mode 100644 lib/socketserver/GwUdpWriter.h diff --git a/lib/api/GwApi.h b/lib/api/GwApi.h index d4d0716..00dbc1f 100644 --- a/lib/api/GwApi.h +++ b/lib/api/GwApi.h @@ -95,6 +95,7 @@ class GwApi{ unsigned long ser2Tx=0; unsigned long tcpSerRx=0; unsigned long tcpSerTx=0; + unsigned long udpwTx=0; int tcpClients=0; unsigned long tcpClRx=0; unsigned long tcpClTx=0; diff --git a/lib/channel/GwChannelList.cpp b/lib/channel/GwChannelList.cpp index 56f4598..9b68b07 100644 --- a/lib/channel/GwChannelList.cpp +++ b/lib/channel/GwChannelList.cpp @@ -6,6 +6,7 @@ #include "GwSocketServer.h" #include "GwSerial.h" #include "GwTcpClient.h" +#include "GwUdpWriter.h" class SerInit{ public: int serial=-1; @@ -241,6 +242,24 @@ static ChannelParam channelParameters[]={ .maxId=-1, .rxstatus=offsetof(GwApi::Status,GwApi::Status::tcpClRx), .txstatus=offsetof(GwApi::Status,GwApi::Status::tcpClTx) + }, + { + .id=UDPW_CHANNEL_ID, + .baud="", + .receive="", + .send=GwConfigDefinitions::udpwEnabled, + .direction="", + .toN2K="", + .readF="", + .writeF=GwConfigDefinitions::udpwWriteFilter, + .preventLog="", + .readAct="", + .writeAct="", + .sendSeasmart=GwConfigDefinitions::udpwSeasmart, + .name="UDPWriter", + .maxId=-1, + .rxstatus=0, + .txstatus=offsetof(GwApi::Status,GwApi::Status::udpwTx) } }; @@ -425,6 +444,13 @@ void GwChannelList::begin(bool fallbackSerial){ ); } addChannel(createChannel(logger,config,TCP_CLIENT_CHANNEL_ID,client)); + + //udp writer + if (config->getBool(GwConfigDefinitions::udpwEnabled)){ + GwUdpWriter *writer=new GwUdpWriter(config,logger,UDPW_CHANNEL_ID); + writer->begin(); + addChannel(createChannel(logger,config,UDPW_CHANNEL_ID,writer)); + } logger->flush(); } String GwChannelList::getMode(int id){ diff --git a/lib/channel/GwChannelList.h b/lib/channel/GwChannelList.h index ba45c66..956b786 100644 --- a/lib/channel/GwChannelList.h +++ b/lib/channel/GwChannelList.h @@ -18,6 +18,7 @@ #define SERIAL2_CHANNEL_ID 3 #define TCP_CLIENT_CHANNEL_ID 4 #define MIN_TCP_CHANNEL_ID 5 +#define UDPW_CHANNEL_ID 20 #define MIN_USER_TASK 200 class GwSocketServer; diff --git a/lib/socketserver/GwUdpWriter.cpp b/lib/socketserver/GwUdpWriter.cpp new file mode 100644 index 0000000..51a5037 --- /dev/null +++ b/lib/socketserver/GwUdpWriter.cpp @@ -0,0 +1,59 @@ +#include "GwUdpWriter.h" +#include <ESPmDNS.h> +#include <errno.h> +#include "GwBuffer.h" +#include "GwSocketConnection.h" +#include "GwSocketHelper.h" + +GwUdpWriter::GwUdpWriter(const GwConfigHandler *config, GwLog *logger, int minId) +{ + this->config = config; + this->logger = logger; + this->minId = minId; +} + +void GwUdpWriter::begin() +{ + fd=socket(AF_INET,SOCK_DGRAM,IPPROTO_IP); + if (fd < 0){ + LOG_ERROR("unable to create udp socket"); + return; + } + int enable = 1; + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)); + setsockopt(fd, SOL_SOCKET, SO_BROADCAST, &enable, sizeof(int)); + port=config->getInt(GwConfigDefinitions::udpwPort); + //TODO: check port + address=config->getString(GwConfigDefinitions::udpwAddress); + LOG_INFO("UDP writer created, address=%s, port=%d", + address.c_str(),port); + inet_pton(AF_INET, address.c_str(), &destination.sin_addr); + destination.sin_family = AF_INET; + destination.sin_port = htons(port); +} + +void GwUdpWriter::loop(bool handleRead, bool handleWrite) +{ + +} + +void GwUdpWriter::readMessages(GwMessageFetcher *writer) +{ + +} +size_t GwUdpWriter::sendToClients(const char *buf, int source,bool partial) +{ + if (source == minId) return 0; + size_t len=strlen(buf); + ssize_t err = sendto(fd,buf,len,0,(struct sockaddr *)&destination, sizeof(destination)); + if (err < 0){ + LOG_DEBUG(GwLog::DEBUG,"UDP writer error sending: %d",errno); + return 0; + } + return err; +} + + +GwUdpWriter::~GwUdpWriter() +{ +} \ No newline at end of file diff --git a/lib/socketserver/GwUdpWriter.h b/lib/socketserver/GwUdpWriter.h new file mode 100644 index 0000000..e98ea67 --- /dev/null +++ b/lib/socketserver/GwUdpWriter.h @@ -0,0 +1,28 @@ +#ifndef _GWUDPWRITER_H +#define _GWUDPWRITER_H +#include "GWConfig.h" +#include "GwLog.h" +#include "GwBuffer.h" +#include "GwChannelInterface.h" +#include <memory> +#include <sys/socket.h> +#include <arpa/inet.h> + +class GwUdpWriter: public GwChannelInterface{ + private: + const GwConfigHandler *config; + GwLog *logger; + int fd=-1; + int minId; + int port; + String address; + struct sockaddr_in destination; + public: + GwUdpWriter(const GwConfigHandler *config,GwLog *logger,int minId); + ~GwUdpWriter(); + void begin(); + virtual void loop(bool handleRead=true,bool handleWrite=true); + virtual size_t sendToClients(const char *buf,int sourceId, bool partialWrite=false); + virtual void readMessages(GwMessageFetcher *writer); +}; +#endif \ No newline at end of file diff --git a/web/config.json b/web/config.json index 5c21eb5..b4336d6 100644 --- a/web/config.json +++ b/web/config.json @@ -826,6 +826,47 @@ "description": "send NMEA2000 as seasmart to remote TCP server", "category": "TCP client" }, + { + "name": "udpwEnabled", + "label": "enable", + "type": "boolean", + "default": "false", + "description":"enable the UDP writer", + "category":"UDP writer" + }, + { + "name": "udpwPort", + "label": "remote port", + "type": "number", + "default": "10110", + "description": "the UDP port we send to", + "category": "UDP writer" + }, + { + "name": "udpwAddress", + "label": "remote address", + "type": "string", + "default": "", + "check": "checkIpAddress", + "description": "the IP address we connect to in the form 192.168.1.2", + "category": "UDP writer" + }, + { + "name": "udpwWriteFilter", + "label": "NMEA write Filter", + "type": "filter", + "default": "", + "description": "filter for NMEA0183 data when writing to remote UDP server\nselect aison|aisoff, set a whitelist or a blacklist with NMEA sentences like RMC,RMB", + "category": "UDP writer" + }, + { + "name": "udpwSeasmart", + "label": "Seasmart out", + "type": "boolean", + "default": "false", + "description": "send NMEA2000 as seasmart to remote UDP server", + "category": "UDP writer" + }, { "name": "wifiClient", "label": "wifi client", From a5827e24d8481d7ae6114f98309300d26bf67ab0 Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Thu, 7 Nov 2024 19:47:27 +0100 Subject: [PATCH 42/58] different modes for UDP writer, allow to select network --- lib/socketserver/GwSocketHelper.h | 5 + lib/socketserver/GwUdpWriter.cpp | 184 ++++++++++++++++++++++++++---- lib/socketserver/GwUdpWriter.h | 51 ++++++++- web/config.json | 34 +++++- 4 files changed, 250 insertions(+), 24 deletions(-) diff --git a/lib/socketserver/GwSocketHelper.h b/lib/socketserver/GwSocketHelper.h index 8dea507..67ba3a6 100644 --- a/lib/socketserver/GwSocketHelper.h +++ b/lib/socketserver/GwSocketHelper.h @@ -17,4 +17,9 @@ class GwSocketHelper{ if (setsockopt(socket, IPPROTO_TCP, TCP_KEEPCNT, &val, sizeof(val)) != ESP_OK) return false; return true; } + static bool isMulticast(const String &addr){ + in_addr iaddr; + if (inet_pton(AF_INET,addr.c_str(),&iaddr) != 1) return false; + return IN_MULTICAST(ntohl(iaddr.s_addr)); + } }; \ No newline at end of file diff --git a/lib/socketserver/GwUdpWriter.cpp b/lib/socketserver/GwUdpWriter.cpp index 51a5037..c91880e 100644 --- a/lib/socketserver/GwUdpWriter.cpp +++ b/lib/socketserver/GwUdpWriter.cpp @@ -4,36 +4,175 @@ #include "GwBuffer.h" #include "GwSocketConnection.h" #include "GwSocketHelper.h" +#include "GWWifi.h" + +GwUdpWriter::WriterSocket::WriterSocket(GwLog *l,int p,const String &src,const String &dst, SourceMode sm) : + sourceMode(sm), source(src), destination(dst), port(p),logger(l) +{ + if (inet_pton(AF_INET, dst.c_str(), &dstA.sin_addr) != 1) + { + LOG_ERROR("UDPW: invalid destination ip address %s", dst.c_str()); + return; + } + if (sourceMode != SourceMode::S_UNBOUND) + { + if (inet_pton(AF_INET, src.c_str(), &srcA) != 1) + { + LOG_ERROR("UDPW: invalid source ip address %s", src.c_str()); + return; + } + } + dstA.sin_family=AF_INET; + dstA.sin_port=htons(port); + fd=socket(AF_INET,SOCK_DGRAM,IPPROTO_IP); + if (fd < 0){ + LOG_ERROR("UDPW: unable to create udp socket: %d",errno); + return; + } + int enable = 1; + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)); + setsockopt(fd, SOL_SOCKET, SO_BROADCAST, &enable, sizeof(int)); + switch (sourceMode) + { + case SourceMode::S_SRC: + { + sockaddr_in bindA; + bindA.sin_family = AF_INET; + bindA.sin_port = htons(0); // let system select + bindA.sin_addr = srcA; + if (bind(fd, (struct sockaddr *)&bindA, sizeof(bindA)) != 0) + { + LOG_ERROR("UDPW: bind failed for address %s: %d", source.c_str(), errno); + ::close(fd); + fd = -1; + return; + } + } + break; + case SourceMode::S_MC: + { + if (setsockopt(fd,IPPROTO_IP,IP_MULTICAST_IF,&srcA,sizeof(srcA)) != 0){ + LOG_ERROR("UDPW: unable to set MC source %s: %d",source.c_str(),errno); + ::close(fd); + fd=-1; + return; + } + int loop=0; + setsockopt(fd,IPPROTO_IP,IP_MULTICAST_LOOP,&loop,sizeof(loop)); + } + break; + default: + //not bound + break; + } +} +bool GwUdpWriter::WriterSocket::changed(const String &newSrc, const String &newDst){ + if (newDst != destination) return true; + if (sourceMode == SourceMode::S_UNBOUND) return false; + return newSrc != source; +} +size_t GwUdpWriter::WriterSocket::send(const char *buf,size_t len){ + if (fd < 0) return 0; + ssize_t err = sendto(fd,buf,len,0,(struct sockaddr *)&dstA, sizeof(dstA)); + if (err < 0){ + LOG_DEBUG(GwLog::DEBUG,"UDPW %s error sending: %d",destination.c_str(), errno); + return 0; + } + return err; +} GwUdpWriter::GwUdpWriter(const GwConfigHandler *config, GwLog *logger, int minId) { this->config = config; this->logger = logger; this->minId = minId; + port=config->getInt(GwConfigDefinitions::udpwPort); + +} +void GwUdpWriter::checkStaSocket(){ + String src; + String bc; + if (type == T_BCAP || type == T_MCAP || type == T_NORM || type == T_UNKNOWN ) return; + bool connected=false; + if (WiFi.isConnected()){ + src=WiFi.localIP().toString(); + bc=WiFi.broadcastIP().toString(); + connected=true; + } + else{ + if (staSocket == nullptr) return; + } + String dst; + WriterSocket::SourceMode sm=WriterSocket::SourceMode::S_SRC; + switch (type){ + case T_BCALL: + case T_BCSTA: + sm=WriterSocket::SourceMode::S_SRC; + dst=bc; + break; + case T_MCALL: + case T_MCSTA: + dst=config->getString(GwConfigDefinitions::udpwMC); + sm=WriterSocket::SourceMode::S_MC; + break; + + } + if (staSocket != nullptr) + { + if (!connected || staSocket->changed(src, dst)) + { + staSocket->close(); + delete staSocket; + staSocket = nullptr; + LOG_INFO("changing/stopping UDPW(sta) socket"); + } + } + if (staSocket == nullptr && connected) + { + LOG_INFO("creating new UDP(sta) socket src=%s, dst=%s", src.c_str(), dst.c_str()); + staSocket = new WriterSocket(logger, port, src, dst, WriterSocket::SourceMode::S_SRC); + } } void GwUdpWriter::begin() { - fd=socket(AF_INET,SOCK_DGRAM,IPPROTO_IP); - if (fd < 0){ - LOG_ERROR("unable to create udp socket"); - return; + if (type != T_UNKNOWN) return; //already started + type=(UType)(config->getInt(GwConfigDefinitions::udpwType)); + LOG_INFO("UDPW begin, mode=%d",(int)type); + String src=WiFi.softAPIP().toString(); + String dst; + WriterSocket::SourceMode sm=WriterSocket::SourceMode::S_UNBOUND; + bool createApSocket=false; + switch(type){ + case T_BCALL: + case T_BCAP: + createApSocket=true; + dst=WiFi.softAPBroadcastIP().toString(); + sm=WriterSocket::SourceMode::S_SRC; + break; + case T_MCALL: + case T_MCAP: + createApSocket=true; + dst=config->getString(GwConfigDefinitions::udpwMC); + sm=WriterSocket::SourceMode::S_SRC; + break; + case T_NORM: + createApSocket=true; + dst=config->getString(GwConfigDefinitions::udpwAddress); + sm=WriterSocket::SourceMode::S_UNBOUND; } - int enable = 1; - setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)); - setsockopt(fd, SOL_SOCKET, SO_BROADCAST, &enable, sizeof(int)); - port=config->getInt(GwConfigDefinitions::udpwPort); - //TODO: check port - address=config->getString(GwConfigDefinitions::udpwAddress); - LOG_INFO("UDP writer created, address=%s, port=%d", - address.c_str(),port); - inet_pton(AF_INET, address.c_str(), &destination.sin_addr); - destination.sin_family = AF_INET; - destination.sin_port = htons(port); + if (createApSocket){ + LOG_INFO("creating new UDPW(ap) socket src=%s, dst=%s", src.c_str(), dst.c_str()); + apSocket=new WriterSocket(logger,port,src,dst,sm); + } + checkStaSocket(); } void GwUdpWriter::loop(bool handleRead, bool handleWrite) { + if (handleWrite){ + checkStaSocket(); + } } @@ -45,12 +184,17 @@ size_t GwUdpWriter::sendToClients(const char *buf, int source,bool partial) { if (source == minId) return 0; size_t len=strlen(buf); - ssize_t err = sendto(fd,buf,len,0,(struct sockaddr *)&destination, sizeof(destination)); - if (err < 0){ - LOG_DEBUG(GwLog::DEBUG,"UDP writer error sending: %d",errno); - return 0; + bool hasSent=false; + size_t res=0; + if (apSocket != nullptr){ + res=apSocket->send(buf,len); + if (res > 0) hasSent=true; } - return err; + if (staSocket != nullptr){ + res=staSocket->send(buf,len); + if (res > 0) hasSent=true; + } + return hasSent?len:0; } diff --git a/lib/socketserver/GwUdpWriter.h b/lib/socketserver/GwUdpWriter.h index e98ea67..e17a17e 100644 --- a/lib/socketserver/GwUdpWriter.h +++ b/lib/socketserver/GwUdpWriter.h @@ -9,14 +9,59 @@ #include <arpa/inet.h> class GwUdpWriter: public GwChannelInterface{ + public: + using UType=enum{ + T_BCALL=0, + T_BCAP=1, + T_BCSTA=2, + T_NORM=3, + T_MCALL=4, + T_MCAP=5, + T_MCSTA=6, + T_UNKNOWN=-1 + }; private: + class WriterSocket{ + public: + int fd=-1; + struct in_addr srcA; + struct sockaddr_in dstA; + String source; + String destination; + int port; + GwLog *logger; + using SourceMode=enum { + S_UNBOUND=0, + S_MC, + S_SRC + }; + SourceMode sourceMode; + WriterSocket(GwLog *logger,int p,const String &src,const String &dst, SourceMode sm); + void close(){ + if (fd > 0){ + ::close(fd); + } + fd=-1; + } + ~WriterSocket(){ + close(); + } + bool changed(const String &newSrc, const String &newDst); + size_t send(const char *buf,size_t len); + }; const GwConfigHandler *config; GwLog *logger; - int fd=-1; + /** + * we use fd/address to send to the AP network + * and fd2,address2 to send to the station network + * for type "normal" we only use fd + */ + WriterSocket *apSocket=nullptr; //also for T_NORM + WriterSocket *staSocket=nullptr; int minId; int port; - String address; - struct sockaddr_in destination; + UType type=T_UNKNOWN; + void checkStaSocket(); public: GwUdpWriter(const GwConfigHandler *config,GwLog *logger,int minId); ~GwUdpWriter(); diff --git a/web/config.json b/web/config.json index b4336d6..3e44a4b 100644 --- a/web/config.json +++ b/web/config.json @@ -842,6 +842,23 @@ "description": "the UDP port we send to", "category": "UDP writer" }, + { + "name": "udpwType", + "label": "remote address type", + "type": "list", + "default": "0", + "description": "to which networks/addresses do we send\nbc-all: send broadcast to AP and wifi client network\nbc-ap: send broadcast to access point only\nbc-cli: send broadcast to wifi client network\nnormal: normal target address\nmc-all: multicast to AP and wifi client network\nmc-ap:multicast to AP network\nmc-cli: muticast to wifi client network", + "list":[ + {"l":"bc-all","v":"0"}, + {"l":"bc-ap","v":"1"}, + {"l":"bc-cli","v":"2"}, + {"l":"normal","v":"3"}, + {"l":"mc-all","v":"4"}, + {"l":"mc-ap","v":"5"}, + {"l":"mc-cli","v":"6"} + ], + "category": "UDP writer" + }, { "name": "udpwAddress", "label": "remote address", @@ -849,7 +866,22 @@ "default": "", "check": "checkIpAddress", "description": "the IP address we connect to in the form 192.168.1.2", - "category": "UDP writer" + "category": "UDP writer", + "condition":{ + "udpwType":["3"] + } + }, + { + "name": "udpwMC", + "label": "multicast address", + "type": "string", + "default": "224.0.0.1", + "check": "checkMCAddress", + "description": "the multicast address we send to 224.0.0.0...239.255.255.255", + "category": "UDP writer", + "condition":{ + "udpwType":["4","5","6"] + } }, { "name": "udpwWriteFilter", From b0d5e27b5a9aac6a3e99c478c44723e4f32326e5 Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Thu, 7 Nov 2024 20:16:58 +0100 Subject: [PATCH 43/58] make seasmart working again, only show counter for enabled directions --- lib/channel/GwChannel.cpp | 16 ++++++++++------ lib/channel/GwChannelList.cpp | 2 +- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/lib/channel/GwChannel.cpp b/lib/channel/GwChannel.cpp index 069b5ae..9c79983 100644 --- a/lib/channel/GwChannel.cpp +++ b/lib/channel/GwChannel.cpp @@ -58,8 +58,6 @@ GwChannel::GwChannel(GwLog *logger, this->name=name; this->sourceId=sourceId; this->maxSourceId=maxSourceId; - this->countIn=new GwCounter<String>(String("count")+name+String("in")); - this->countOut=new GwCounter<String>(String("count")+name+String("out")); this->impl=NULL; this->receiver=new GwChannelMessageReceiver(logger,this); this->actisenseReader=NULL; @@ -100,6 +98,12 @@ void GwChannel::begin( actisenseReader->SetReadStream(channelStream); } } + if (nmeaIn || readActisense){ + this->countIn=new GwCounter<String>(String("count")+name+String("in")); + } + if (nmeaOut || seaSmartOut || writeActisense){ + this->countOut=new GwCounter<String>(String("count")+name+String("out")); + } } void GwChannel::setImpl(GwChannelInterface *impl){ this->impl=impl; @@ -135,10 +139,10 @@ void GwChannel::updateCounter(const char *msg, bool out) } if (key[0] == 0) return; if (out){ - countOut->add(key); + if (countOut) countOut->add(key); } else{ - countIn->add(key); + if (countIn) countIn->add(key); } } @@ -209,7 +213,7 @@ void GwChannel::parseActisense(N2kHandler handler){ tN2kMsg N2kMsg; while (actisenseReader->GetMessageFromStream(N2kMsg)) { - countIn->add(String(N2kMsg.PGN)); + if(countIn) countIn->add(String(N2kMsg.PGN)); handler(N2kMsg,sourceId); } } @@ -220,7 +224,7 @@ void GwChannel::sendActisense(const tN2kMsg &msg, int sourceId){ //so we can check it here if (maxSourceId < 0 && this->sourceId == sourceId) return; if (sourceId >= this->sourceId && sourceId <= maxSourceId) return; - countOut->add(String(msg.PGN)); + if(countOut) countOut->add(String(msg.PGN)); msg.SendInActisenseFormat(channelStream); } diff --git a/lib/channel/GwChannelList.cpp b/lib/channel/GwChannelList.cpp index 9b68b07..b08bbfc 100644 --- a/lib/channel/GwChannelList.cpp +++ b/lib/channel/GwChannelList.cpp @@ -356,7 +356,7 @@ static GwChannel * createChannel(GwLog *logger, GwConfigHandler *config, int id, canRead, config->getString(param->readF), config->getString(param->writeF), - false, + config->getBool(param->sendSeasmart), config->getBool(param->toN2K), config->getBool(param->readAct), config->getBool(param->writeAct)); From 82f5e179875e9cfd4bfa1a5fd9f50c496638fe32 Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Fri, 8 Nov 2024 21:00:25 +0100 Subject: [PATCH 44/58] intermediate: udp reader --- lib/api/GwApi.h | 1 + lib/channel/GwChannelList.cpp | 26 +++++ lib/channel/GwChannelList.h | 1 + lib/queue/GwBuffer.cpp | 12 ++- lib/queue/GwBuffer.h | 5 +- lib/socketserver/GwSocketHelper.h | 3 + lib/socketserver/GwUdpReader.cpp | 158 ++++++++++++++++++++++++++++++ lib/socketserver/GwUdpReader.h | 45 +++++++++ web/config.json | 143 ++++++++++++++++++++++++--- web/index.js | 36 +++++-- 10 files changed, 406 insertions(+), 24 deletions(-) create mode 100644 lib/socketserver/GwUdpReader.cpp create mode 100644 lib/socketserver/GwUdpReader.h diff --git a/lib/api/GwApi.h b/lib/api/GwApi.h index 00dbc1f..5a24e92 100644 --- a/lib/api/GwApi.h +++ b/lib/api/GwApi.h @@ -96,6 +96,7 @@ class GwApi{ unsigned long tcpSerRx=0; unsigned long tcpSerTx=0; unsigned long udpwTx=0; + unsigned long udprRx=0; int tcpClients=0; unsigned long tcpClRx=0; unsigned long tcpClTx=0; diff --git a/lib/channel/GwChannelList.cpp b/lib/channel/GwChannelList.cpp index b08bbfc..4371cef 100644 --- a/lib/channel/GwChannelList.cpp +++ b/lib/channel/GwChannelList.cpp @@ -7,6 +7,7 @@ #include "GwSerial.h" #include "GwTcpClient.h" #include "GwUdpWriter.h" +#include "GwUdpReader.h" class SerInit{ public: int serial=-1; @@ -260,8 +261,27 @@ static ChannelParam channelParameters[]={ .maxId=-1, .rxstatus=0, .txstatus=offsetof(GwApi::Status,GwApi::Status::udpwTx) + }, + { + .id=UDPR_CHANNEL_ID, + .baud="", + .receive=GwConfigDefinitions::udprEnabled, + .send="", + .direction="", + .toN2K=GwConfigDefinitions::udprToN2k, + .readF=GwConfigDefinitions::udprReadFilter, + .writeF="", + .preventLog="", + .readAct="", + .writeAct="", + .sendSeasmart="", + .name="UDPReader", + .maxId=-1, + .rxstatus=offsetof(GwApi::Status,GwApi::Status::udprRx), + .txstatus=0 } + }; template<typename T> @@ -451,6 +471,12 @@ void GwChannelList::begin(bool fallbackSerial){ writer->begin(); addChannel(createChannel(logger,config,UDPW_CHANNEL_ID,writer)); } + //udp reader + if (config->getBool(GwConfigDefinitions::udprEnabled)){ + GwUdpReader *reader=new GwUdpReader(config,logger,UDPR_CHANNEL_ID); + reader->begin(); + addChannel(createChannel(logger,config,UDPR_CHANNEL_ID,reader)); + } logger->flush(); } String GwChannelList::getMode(int id){ diff --git a/lib/channel/GwChannelList.h b/lib/channel/GwChannelList.h index 956b786..f724716 100644 --- a/lib/channel/GwChannelList.h +++ b/lib/channel/GwChannelList.h @@ -19,6 +19,7 @@ #define TCP_CLIENT_CHANNEL_ID 4 #define MIN_TCP_CHANNEL_ID 5 #define UDPW_CHANNEL_ID 20 +#define UDPR_CHANNEL_ID 21 #define MIN_USER_TASK 200 class GwSocketServer; diff --git a/lib/queue/GwBuffer.cpp b/lib/queue/GwBuffer.cpp index 8e12934..de0f070 100644 --- a/lib/queue/GwBuffer.cpp +++ b/lib/queue/GwBuffer.cpp @@ -21,7 +21,7 @@ GwBuffer::~GwBuffer(){ } void GwBuffer::reset(String reason) { - LOG_DEBUG(GwLog::LOG,"reseting buffer %s, reason %s",this->name.c_str(),reason.c_str()); + if (! reason.isEmpty())LOG_DEBUG(GwLog::LOG,"reseting buffer %s, reason %s",this->name.c_str(),reason.c_str()); writePointer = buffer; readPointer = buffer; lp("reset"); @@ -33,6 +33,16 @@ size_t GwBuffer::freeSpace() } return readPointer - writePointer - 1; } +size_t GwBuffer::continousSpace() const{ + if (readPointer <= writePointer){ + return bufferSize-offset(writePointer); + } + return readPointer-writePointer-1; +} +void GwBuffer::moveWp(size_t offset){ + if (offset > continousSpace()) return; + writePointer+=offset; +} size_t GwBuffer::usedSpace() { if (readPointer <= writePointer) diff --git a/lib/queue/GwBuffer.h b/lib/queue/GwBuffer.h index 17490ec..aa67e04 100644 --- a/lib/queue/GwBuffer.h +++ b/lib/queue/GwBuffer.h @@ -33,7 +33,7 @@ class GwBuffer{ uint8_t *buffer; uint8_t *writePointer; uint8_t *readPointer; - size_t offset(uint8_t* ptr){ + size_t offset(uint8_t* ptr) const{ return (size_t)(ptr-buffer); } GwLog *logger; @@ -54,6 +54,9 @@ class GwBuffer{ * find the first occurance of x in the buffer, -1 if not found */ int findChar(char x); + uint8_t *getWp(){return writePointer;} + size_t continousSpace() const; //free space from wp + void moveWp(size_t offset); //move the wp forward }; #endif \ No newline at end of file diff --git a/lib/socketserver/GwSocketHelper.h b/lib/socketserver/GwSocketHelper.h index 67ba3a6..f153d45 100644 --- a/lib/socketserver/GwSocketHelper.h +++ b/lib/socketserver/GwSocketHelper.h @@ -22,4 +22,7 @@ class GwSocketHelper{ if (inet_pton(AF_INET,addr.c_str(),&iaddr) != 1) return false; return IN_MULTICAST(ntohl(iaddr.s_addr)); } + static bool equals(const in_addr &left, const in_addr &right){ + return left.s_addr == right.s_addr; + } }; \ No newline at end of file diff --git a/lib/socketserver/GwUdpReader.cpp b/lib/socketserver/GwUdpReader.cpp new file mode 100644 index 0000000..e8b619e --- /dev/null +++ b/lib/socketserver/GwUdpReader.cpp @@ -0,0 +1,158 @@ +#include "GwUdpReader.h" +#include <ESPmDNS.h> +#include <errno.h> +#include "GwBuffer.h" +#include "GwSocketConnection.h" +#include "GwSocketHelper.h" +#include "GWWifi.h" + + +GwUdpReader::GwUdpReader(const GwConfigHandler *config, GwLog *logger, int minId) +{ + this->config = config; + this->logger = logger; + this->minId = minId; + port=config->getInt(GwConfigDefinitions::udprPort); + buffer= new GwBuffer(logger,GwBuffer::RX_BUFFER_SIZE,"udprd"); +} + +void GwUdpReader::createAndBind(){ + if (fd >= 0){ + ::close(fd); + } + if (currentStationIp.isEmpty() && (type == T_STA || type == T_MCSTA)) return; + fd=socket(AF_INET,SOCK_DGRAM,IPPROTO_IP); + if (fd < 0){ + LOG_ERROR("UDPR: unable to create udp socket: %d",errno); + return; + } + int enable = 1; + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)); + if (type == T_STA) + { + if (inet_pton(AF_INET, currentStationIp.c_str(), &listenA.sin_addr) != 1) + { + LOG_ERROR("UDPR: invalid station ip address %s", currentStationIp.c_str()); + close(fd); + fd = -1; + return; + } + } + if (bind(fd,(struct sockaddr *)&listenA,sizeof(listenA)) < 0){ + LOG_ERROR("UDPR: unable to bind: %d",errno); + close(fd); + fd=-1; + return; + } + LOG_INFO("UDPR: socket created and bound"); + if (type != T_MCALL && type != T_MCAP && type != T_MCSTA) { + return; + } + struct ip_mreq mc; + String mcAddr=config->getString(GwConfigDefinitions::udprMC); + if (inet_pton(AF_INET,mcAddr.c_str(),&mc.imr_multiaddr) != 1){ + LOG_ERROR("UDPR: invalid multicast addr %s",mcAddr.c_str()); + ::close(fd); + fd=-1; + return; + } + if (type == T_MCALL || type == T_MCAP){ + mc.imr_interface=apAddr; + int res=setsockopt(fd,IPPROTO_IP,IP_ADD_MEMBERSHIP,&mc,sizeof(mc)); + if (res != 0){ + LOG_ERROR("UDPR: unable to add MC membership for AP:%d",errno); + } + else{ + LOG_INFO("UDPR: membership for %s for AP",mcAddr.c_str()); + } + } + if (!currentStationIp.isEmpty() && (type == T_MCALL || type == T_MCSTA)) + { + mc.imr_interface = staAddr; + int res = setsockopt(fd, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mc, sizeof(mc)); + if (res != 0) + { + LOG_ERROR("UDPR: unable to add MC membership for STA:%d", errno); + } + else{ + LOG_INFO("UDPR: membership for %s for STA %s",mcAddr.c_str(),currentStationIp.c_str()); + } + } +} + +void GwUdpReader::begin() +{ + if (type != T_UNKNOWN) return; //already started + type=(UType)(config->getInt(GwConfigDefinitions::udprType)); + LOG_INFO("UDPR begin, mode=%d",(int)type); + port=config->getInt(GwConfigDefinitions::udprPort); + listenA.sin_family=AF_INET; + listenA.sin_port=htons(port); + if (type != T_STA){ + listenA.sin_addr.s_addr=htonl(INADDR_ANY); + } + String ap=WiFi.softAPIP().toString(); + if (inet_pton(AF_INET, ap.c_str(), &apAddr) != 1) + { + LOG_ERROR("UDPR: invalid ap ip address %s", ap.c_str()); + return; + } + String sta; + if (WiFi.isConnected()) sta=WiFi.localIP().toString(); + setStationAdd(sta); + createAndBind(); +} + +bool GwUdpReader::setStationAdd(const String &sta){ + if (sta == currentStationIp) return false; + currentStationIp=sta; + if (inet_pton(AF_INET, currentStationIp.c_str(), &staAddr) != 1){ + LOG_ERROR("UDPR: invalid station ip address %s", currentStationIp.c_str()); + return false; + } + LOG_INFO("UDPR: new station IP %s",currentStationIp.c_str()); + return true; +} +void GwUdpReader::loop(bool handleRead, bool handleWrite) +{ + if (handleRead){ + if (type == T_STA || type == T_MCALL || type == T_MCSTA){ + //only change anything if we considered the station IP + String nextStationIp; + if (WiFi.isConnected()){ + String nextStationIp=WiFi.localIP().toString(); + } + if (setStationAdd(nextStationIp)){ + LOG_INFO("UDPR: wifi client IP changed, restart"); + createAndBind(); + } + } + } + +} + +void GwUdpReader::readMessages(GwMessageFetcher *writer) +{ + if (fd < 0) return; + //we expect one NMEA message in one UDP packet + buffer->reset(); + struct sockaddr_in from; + socklen_t fromLen=sizeof(from); + ssize_t res=recvfrom(fd,buffer->getWp(),buffer->continousSpace(),MSG_DONTWAIT, + (struct sockaddr*)&from,&fromLen); + if (res <= 0) return; + if (GwSocketHelper::equals(from.sin_addr,apAddr)) return; + if (!currentStationIp.isEmpty() && (GwSocketHelper::equals(from.sin_addr,staAddr))) return; + buffer->moveWp(res); + LOG_DEBUG(GwLog::DEBUG,"UDPR: received %d bytes",res); + writer->handleBuffer(buffer); +} +size_t GwUdpReader::sendToClients(const char *buf, int source,bool partial) +{ + return 0; +} + + +GwUdpReader::~GwUdpReader() +{ +} \ No newline at end of file diff --git a/lib/socketserver/GwUdpReader.h b/lib/socketserver/GwUdpReader.h new file mode 100644 index 0000000..08c56bb --- /dev/null +++ b/lib/socketserver/GwUdpReader.h @@ -0,0 +1,45 @@ +#ifndef _GWUDPREADER_H +#define _GWUDPREADER_H +#include "GWConfig.h" +#include "GwLog.h" +#include "GwBuffer.h" +#include "GwChannelInterface.h" +#include <memory> +#include <sys/socket.h> +#include <arpa/inet.h> + +class GwUdpReader: public GwChannelInterface{ + public: + using UType=enum{ + T_ALL=0, + T_AP=1, + T_STA=2, + T_MCALL=4, + T_MCAP=5, + T_MCSTA=6, + T_UNKNOWN=-1 + }; + private: + const GwConfigHandler *config; + GwLog *logger; + int minId; + int port; + int fd=-1; + struct sockaddr_in listenA; + String listenIp; + String currentStationIp; + struct in_addr apAddr; + struct in_addr staAddr; + UType type=T_UNKNOWN; + void createAndBind(); + bool setStationAdd(const String &sta); + GwBuffer *buffer=nullptr; + public: + GwUdpReader(const GwConfigHandler *config,GwLog *logger,int minId); + ~GwUdpReader(); + void begin(); + virtual void loop(bool handleRead=true,bool handleWrite=true); + virtual size_t sendToClients(const char *buf,int sourceId, bool partialWrite=false); + virtual void readMessages(GwMessageFetcher *writer); +}; +#endif \ No newline at end of file diff --git a/web/config.json b/web/config.json index 3e44a4b..0b75d27 100644 --- a/web/config.json +++ b/web/config.json @@ -691,6 +691,7 @@ "label": "TCP port", "type": "number", "default": "10110", + "check":"checkPort", "description": "the TCP port we listen on", "category": "TCP server" }, @@ -766,8 +767,12 @@ "label": "remote port", "type": "number", "default": "10110", + "check":"checkPort", "description": "the TCP port we connect to", - "category": "TCP client" + "category": "TCP client", + "condition":{ + "tclEnabled":"true" + } }, { "name": "remoteAddress", @@ -776,7 +781,10 @@ "default": "", "check": "checkIpAddress", "description": "the IP address we connect to in the form 192.168.1.2\nor an MDNS name like ESP32NMEA2K.local", - "category": "TCP client" + "category": "TCP client", + "condition":{ + "tclEnabled":"true" + } }, { "name": "sendTCL", @@ -784,7 +792,10 @@ "type": "boolean", "default": "true", "description": "send out NMEA data to remote TCP server", - "category": "TCP client" + "category": "TCP client", + "condition":{ + "tclEnabled":"true" + } }, { "name": "readTCL", @@ -792,7 +803,10 @@ "type": "boolean", "default": "true", "description": "receive NMEA data from remote TCP server", - "category": "TCP client" + "category": "TCP client", + "condition":{ + "tclEnabled":"true" + } }, { "name": "tclToN2k", @@ -800,7 +814,10 @@ "type": "boolean", "default": "true", "description": "convert NMEA0183 from remote TCP server to NMEA2000", - "category": "TCP client" + "category": "TCP client", + "condition":{ + "tclEnabled":"true" + } }, { "name": "tclReadFilter", @@ -808,7 +825,10 @@ "type": "filter", "default": "", "description": "filter for NMEA0183 data when reading from remote TCP server\nselect aison|aisoff, set a whitelist or a blacklist with NMEA sentences like RMC,RMB", - "category": "TCP client" + "category": "TCP client", + "condition":{ + "tclEnabled":"true" + } }, { "name": "tclWriteFilter", @@ -816,7 +836,10 @@ "type": "filter", "default": "", "description": "filter for NMEA0183 data when writing to remote TCP server\nselect aison|aisoff, set a whitelist or a blacklist with NMEA sentences like RMC,RMB", - "category": "TCP client" + "category": "TCP client", + "condition":{ + "tclEnabled":"true" + } }, { "name": "tclSeasmart", @@ -824,7 +847,10 @@ "type": "boolean", "default": "false", "description": "send NMEA2000 as seasmart to remote TCP server", - "category": "TCP client" + "category": "TCP client", + "condition":{ + "tclEnabled":"true" + } }, { "name": "udpwEnabled", @@ -840,7 +866,11 @@ "type": "number", "default": "10110", "description": "the UDP port we send to", - "category": "UDP writer" + "check":"checkPort", + "category": "UDP writer", + "condition":{ + "udpwEnabled":"true" + } }, { "name": "udpwType", @@ -857,7 +887,10 @@ {"l":"mc-ap","v":"5"}, {"l":"mc-cli","v":"6"} ], - "category": "UDP writer" + "category": "UDP writer", + "condition":{ + "udpwEnabled":"true" + } }, { "name": "udpwAddress", @@ -868,7 +901,8 @@ "description": "the IP address we connect to in the form 192.168.1.2", "category": "UDP writer", "condition":{ - "udpwType":["3"] + "udpwType":["3"], + "udpwEnabled":"true" } }, { @@ -880,7 +914,8 @@ "description": "the multicast address we send to 224.0.0.0...239.255.255.255", "category": "UDP writer", "condition":{ - "udpwType":["4","5","6"] + "udpwType":["4","5","6"], + "udpwEnabled":"true" } }, { @@ -889,7 +924,10 @@ "type": "filter", "default": "", "description": "filter for NMEA0183 data when writing to remote UDP server\nselect aison|aisoff, set a whitelist or a blacklist with NMEA sentences like RMC,RMB", - "category": "UDP writer" + "category": "UDP writer", + "condition":{ + "udpwEnabled":"true" + } }, { "name": "udpwSeasmart", @@ -897,7 +935,84 @@ "type": "boolean", "default": "false", "description": "send NMEA2000 as seasmart to remote UDP server", - "category": "UDP writer" + "category": "UDP writer", + "condition":{ + "udpwEnabled":"true" + } + }, + { + "name": "udprEnabled", + "label": "enable", + "type": "boolean", + "default": "false", + "description":"enable the UDP reader", + "category":"UDP reader" + }, + { + "name": "udprPort", + "label": "local port", + "type": "number", + "default": "10110", + "check":"checkPort", + "description": "the UDP port we listen on", + "category": "UDP reader", + "condition":{ + "udprEnabled":"true" + } + }, + { + "name": "udprType", + "label": "local address type", + "type": "list", + "default": "0", + "description": "to which networks/addresses do we listen\nall: listen on AP and wifi client network\nap: listen in access point network only\ncli: listen in wifi client network\nmc-all: receive multicast from AP and wifi client network\nmc-ap:receive multicast from AP network\nmc-cli: receive muticast wifi client network", + "list":[ + {"l":"all","v":"0"}, + {"l":"ap","v":"1"}, + {"l":"cli","v":"2"}, + {"l":"mc-all","v":"4"}, + {"l":"mc-ap","v":"5"}, + {"l":"mc-cli","v":"6"} + ], + "category": "UDP reader", + "condition":{ + "udprEnabled":"true" + } + }, + { + "name": "udprToN2k", + "label": "to NMEA2000", + "type": "boolean", + "default": "true", + "description": "convert NMEA0183 from UDP to NMEA2000", + "category": "UDP reader", + "condition":{ + "udprEnabled":"true" + } + }, + { + "name": "udprMC", + "label": "multicast address", + "type": "string", + "default": "224.0.0.1", + "check": "checkMCAddress", + "description": "the multicast address we listen on 224.0.0.0...239.255.255.255", + "category": "UDP reader", + "condition":{ + "udprType":["4","5","6"], + "udprEnabled":"true" + } + }, + { + "name": "udprReadFilter", + "label": "NMEA read Filter", + "type": "filter", + "default": "", + "description": "filter for NMEA0183 data when receiving\nselect aison|aisoff, set a whitelist or a blacklist with NMEA sentences like RMC,RMB", + "category": "UDP reader", + "condition":{ + "udprEnabled":"true" + } }, { "name": "wifiClient", diff --git a/web/index.js b/web/index.js index 337c21c..6962506 100644 --- a/web/index.js +++ b/web/index.js @@ -181,6 +181,12 @@ } } + checkers.checkPort=function(v,allValues,def){ + let parsed=parseInt(v); + if (isNaN(parsed)) return "must be a number"; + if (parsed <1 || parsed >= 65536) return "port must be in the range 1..65536"; + } + checkers.checkSystemName=function(v) { //2...32 characters for ssid let allowed = v.replace(/[^a-zA-Z0-9]*/g, ''); @@ -213,13 +219,20 @@ } checkers.checkIpAddress=function(v, allValues, def) { - if (allValues.tclEnabled != "true") return; if (!v) return "cannot be empty"; if (!v.match(/[0-9]*\.[0-9]*\.[0-9]*\.[0-9]*/) && !v.match(/.*\.local/)) return "must be either in the form 192.168.1.1 or xxx.local"; } + checkers.checkMCAddress=function(v, allValues, def) { + if (!v) return "cannot be empty"; + if (!v.match(/[0-9]*\.[0-9]*\.[0-9]*\.[0-9]*/)) + return "must be in the form 224.0.0.1"; + let parts=v.split("."); + let o1=parseInt(v[0]); + if (o1 < 224 || o1 > 239) return "mulicast address must be in the range 224.0.0.0 to 239.255.255.255" + } checkers.checkXDR=function(v, allValues) { if (!v) return; let parts = v.split(','); @@ -264,21 +277,22 @@ continue; } let check = v.getAttribute('data-check'); - if (check) { + if (check && conditionOk(name)) { + let cfgDef=getConfigDefition(name); let checkFunction=checkers[check]; if (typeof (checkFunction) === 'function') { if (! loggedChecks[check]){ loggedChecks[check]=true; //console.log("check:"+check); } - let res = checkFunction(v.value, allValues, getConfigDefition(name)); + let res = checkFunction(v.value, allValues, cfgDef); if (res) { let value = v.value; if (v.type === 'password') value = "******"; let label = v.getAttribute('data-label'); if (!label) label = v.getAttribute('name'); v.classList.add("error"); - alert("invalid config for " + label + "(" + value + "):\n" + res); + alert("invalid config for "+cfgDef.category+":" + label + "(" + value + "):\n" + res); return; } } @@ -472,10 +486,10 @@ if (!(condition instanceof Array)) condition = [condition]; return condition; } - function checkCondition(element) { - let name = element.getAttribute('name'); + + function conditionOk(name){ let condition = getConditions(name); - if (!condition) return; + if (!condition) return true; let visible = false; if (!condition instanceof Array) condition = [condition]; condition.forEach(function (cel) { @@ -493,7 +507,13 @@ } } if (lvis) visible = true; - }); + }); + return visible; + } + + function checkCondition(element) { + let name = element.getAttribute('name'); + let visible=conditionOk(name); let row = closestParent(element, 'row'); if (!row) return; if (visible) row.classList.remove('hidden'); From 4bded7bbb4e40f049b3c51280b4d46fcbf24c954 Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Mon, 11 Nov 2024 19:52:44 +0100 Subject: [PATCH 45/58] make sta udp receiver working --- lib/socketserver/GwUdpReader.cpp | 2 +- web/index.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/socketserver/GwUdpReader.cpp b/lib/socketserver/GwUdpReader.cpp index e8b619e..d7efbb9 100644 --- a/lib/socketserver/GwUdpReader.cpp +++ b/lib/socketserver/GwUdpReader.cpp @@ -120,7 +120,7 @@ void GwUdpReader::loop(bool handleRead, bool handleWrite) //only change anything if we considered the station IP String nextStationIp; if (WiFi.isConnected()){ - String nextStationIp=WiFi.localIP().toString(); + nextStationIp=WiFi.localIP().toString(); } if (setStationAdd(nextStationIp)){ LOG_INFO("UDPR: wifi client IP changed, restart"); diff --git a/web/index.js b/web/index.js index 6962506..a82f79e 100644 --- a/web/index.js +++ b/web/index.js @@ -229,7 +229,7 @@ if (!v.match(/[0-9]*\.[0-9]*\.[0-9]*\.[0-9]*/)) return "must be in the form 224.0.0.1"; let parts=v.split("."); - let o1=parseInt(v[0]); + let o1=parseInt(parts[0]); if (o1 < 224 || o1 > 239) return "mulicast address must be in the range 224.0.0.0 to 239.255.255.255" } From b4eaad4dbfbd0c47bd4bb385669fc63993c5ce17 Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Mon, 11 Nov 2024 20:07:22 +0100 Subject: [PATCH 46/58] bind mc udp receiver to mc address --- lib/socketserver/GwUdpReader.cpp | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/lib/socketserver/GwUdpReader.cpp b/lib/socketserver/GwUdpReader.cpp index d7efbb9..b88848c 100644 --- a/lib/socketserver/GwUdpReader.cpp +++ b/lib/socketserver/GwUdpReader.cpp @@ -49,13 +49,7 @@ void GwUdpReader::createAndBind(){ return; } struct ip_mreq mc; - String mcAddr=config->getString(GwConfigDefinitions::udprMC); - if (inet_pton(AF_INET,mcAddr.c_str(),&mc.imr_multiaddr) != 1){ - LOG_ERROR("UDPR: invalid multicast addr %s",mcAddr.c_str()); - ::close(fd); - fd=-1; - return; - } + mc.imr_multiaddr=listenA.sin_addr; if (type == T_MCALL || type == T_MCAP){ mc.imr_interface=apAddr; int res=setsockopt(fd,IPPROTO_IP,IP_ADD_MEMBERSHIP,&mc,sizeof(mc)); @@ -63,7 +57,7 @@ void GwUdpReader::createAndBind(){ LOG_ERROR("UDPR: unable to add MC membership for AP:%d",errno); } else{ - LOG_INFO("UDPR: membership for %s for AP",mcAddr.c_str()); + LOG_INFO("UDPR: membership for for AP"); } } if (!currentStationIp.isEmpty() && (type == T_MCALL || type == T_MCSTA)) @@ -75,7 +69,7 @@ void GwUdpReader::createAndBind(){ LOG_ERROR("UDPR: unable to add MC membership for STA:%d", errno); } else{ - LOG_INFO("UDPR: membership for %s for STA %s",mcAddr.c_str(),currentStationIp.c_str()); + LOG_INFO("UDPR: membership for STA %s",currentStationIp.c_str()); } } } @@ -88,15 +82,27 @@ void GwUdpReader::begin() port=config->getInt(GwConfigDefinitions::udprPort); listenA.sin_family=AF_INET; listenA.sin_port=htons(port); - if (type != T_STA){ - listenA.sin_addr.s_addr=htonl(INADDR_ANY); - } + listenA.sin_addr.s_addr=htonl(INADDR_ANY); //default String ap=WiFi.softAPIP().toString(); if (inet_pton(AF_INET, ap.c_str(), &apAddr) != 1) { LOG_ERROR("UDPR: invalid ap ip address %s", ap.c_str()); return; } + if (type == T_MCALL || type == T_MCAP || type == T_MCSTA){ + String mcAddr=config->getString(GwConfigDefinitions::udprMC); + if (inet_pton(AF_INET, mcAddr.c_str(), &listenA.sin_addr) != 1) + { + LOG_ERROR("UDPR: invalid mc address %s", mcAddr.c_str()); + close(fd); + fd = -1; + return; + } + LOG_INFO("UDPR: using multicast address %s",mcAddr.c_str()); + } + if (type == T_AP){ + listenA.sin_addr=apAddr; + } String sta; if (WiFi.isConnected()) sta=WiFi.localIP().toString(); setStationAdd(sta); From 098b9ba55826990c9fc6e57da7a5835765a258ea Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Mon, 11 Nov 2024 20:28:57 +0100 Subject: [PATCH 47/58] use GwBuffer::fillData for udp receive --- lib/queue/GwBuffer.cpp | 10 ---------- lib/queue/GwBuffer.h | 5 +---- lib/socketserver/GwUdpReader.cpp | 19 +++++++++++-------- 3 files changed, 12 insertions(+), 22 deletions(-) diff --git a/lib/queue/GwBuffer.cpp b/lib/queue/GwBuffer.cpp index de0f070..66a8d75 100644 --- a/lib/queue/GwBuffer.cpp +++ b/lib/queue/GwBuffer.cpp @@ -33,16 +33,6 @@ size_t GwBuffer::freeSpace() } return readPointer - writePointer - 1; } -size_t GwBuffer::continousSpace() const{ - if (readPointer <= writePointer){ - return bufferSize-offset(writePointer); - } - return readPointer-writePointer-1; -} -void GwBuffer::moveWp(size_t offset){ - if (offset > continousSpace()) return; - writePointer+=offset; -} size_t GwBuffer::usedSpace() { if (readPointer <= writePointer) diff --git a/lib/queue/GwBuffer.h b/lib/queue/GwBuffer.h index aa67e04..145984f 100644 --- a/lib/queue/GwBuffer.h +++ b/lib/queue/GwBuffer.h @@ -18,9 +18,9 @@ class GwMessageFetcher{ * buffer to safely inserte data if it fits * and to write out data if possible */ -typedef size_t (*GwBufferHandleFunction)(uint8_t *buffer, size_t len, void *param); class GwBuffer{ public: + using GwBufferHandleFunction=std::function<size_t(uint8_t *buffer, size_t len, void *param)>; static const size_t TX_BUFFER_SIZE=1620; // app. 20 NMEA messages static const size_t RX_BUFFER_SIZE=600; // enough for 1 NMEA message or actisense message or seasmart message typedef enum { @@ -54,9 +54,6 @@ class GwBuffer{ * find the first occurance of x in the buffer, -1 if not found */ int findChar(char x); - uint8_t *getWp(){return writePointer;} - size_t continousSpace() const; //free space from wp - void moveWp(size_t offset); //move the wp forward }; #endif \ No newline at end of file diff --git a/lib/socketserver/GwUdpReader.cpp b/lib/socketserver/GwUdpReader.cpp index b88848c..612eb10 100644 --- a/lib/socketserver/GwUdpReader.cpp +++ b/lib/socketserver/GwUdpReader.cpp @@ -142,15 +142,18 @@ void GwUdpReader::readMessages(GwMessageFetcher *writer) if (fd < 0) return; //we expect one NMEA message in one UDP packet buffer->reset(); - struct sockaddr_in from; - socklen_t fromLen=sizeof(from); - ssize_t res=recvfrom(fd,buffer->getWp(),buffer->continousSpace(),MSG_DONTWAIT, + size_t rd=buffer->fillData(buffer->freeSpace(), + [this](uint8_t *rcvb,size_t rcvlen,void *param)->size_t{ + struct sockaddr_in from; + socklen_t fromLen=sizeof(from); + ssize_t res=recvfrom(fd,rcvb,rcvlen,MSG_DONTWAIT, (struct sockaddr*)&from,&fromLen); - if (res <= 0) return; - if (GwSocketHelper::equals(from.sin_addr,apAddr)) return; - if (!currentStationIp.isEmpty() && (GwSocketHelper::equals(from.sin_addr,staAddr))) return; - buffer->moveWp(res); - LOG_DEBUG(GwLog::DEBUG,"UDPR: received %d bytes",res); + if (res <= 0) return 0; + if (GwSocketHelper::equals(from.sin_addr,apAddr)) return 0; + if (!currentStationIp.isEmpty() && (GwSocketHelper::equals(from.sin_addr,staAddr))) return 0; + return res; + },this); + if (buffer->usedSpace() > 0)(GwLog::DEBUG,"UDPR: received %d bytes",buffer->usedSpace()); writer->handleBuffer(buffer); } size_t GwUdpReader::sendToClients(const char *buf, int source,bool partial) From 4504b8832f262f8c2816929de61031f1d8a48024 Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Wed, 13 Nov 2024 10:59:00 +0100 Subject: [PATCH 48/58] #66: pick minimal changes of latest version from NMEA0183-AIS --- lib/nmea2ktoais/NMEA0183AISMessages.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/nmea2ktoais/NMEA0183AISMessages.cpp b/lib/nmea2ktoais/NMEA0183AISMessages.cpp index 081a1b6..a0f9ec0 100644 --- a/lib/nmea2ktoais/NMEA0183AISMessages.cpp +++ b/lib/nmea2ktoais/NMEA0183AISMessages.cpp @@ -219,7 +219,7 @@ bool SetAISClassBMessage18(tNMEA0183AISMsg &NMEA0183AISMsg, uint8_t MessageID, u bool SetAISClassBMessage24PartA(tNMEA0183AISMsg &NMEA0183AISMsg, uint8_t MessageID, uint8_t Repeat, uint32_t UserID, char *Name) { bool found = false; - for (int i = 0; i < vships.size(); i++) { + for (size_t i = 0; i < vships.size(); i++) { if ( vships[i]->_userID == UserID ) { found = true; break; From 7ea500bad650b17bc4210fb94422e3ffd5674d00 Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Wed, 13 Nov 2024 15:08:27 +0100 Subject: [PATCH 49/58] #87: include system name in export file name --- web/index.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/index.js b/web/index.js index a82f79e..eabd250 100644 --- a/web/index.js +++ b/web/index.js @@ -1071,8 +1071,12 @@ function formatDateForFilename(usePrefix, d) { let rt = ""; if (usePrefix) { + let hdl= document.getElementById('headline'); + if (hdl){ + rt=hdl.textContent+"_"; + } let fwt = document.querySelector('.status-fwtype'); - if (fwt) rt = fwt.textContent; + if (fwt) rt += fwt.textContent; rt += "_"; } if (!d) d = new Date(); From 2c87be78db93550762ddc4cf9e7ed251cd119c3a Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Wed, 13 Nov 2024 17:05:17 +0100 Subject: [PATCH 50/58] make changing timeouts working correctly --- lib/boatData/GwBoatData.cpp | 62 ++++++++++++++++++++++--------------- lib/boatData/GwBoatData.h | 19 +++++++++--- src/main.cpp | 1 + 3 files changed, 52 insertions(+), 30 deletions(-) diff --git a/lib/boatData/GwBoatData.cpp b/lib/boatData/GwBoatData.cpp index 4be1450..39d22c8 100644 --- a/lib/boatData/GwBoatData.cpp +++ b/lib/boatData/GwBoatData.cpp @@ -48,12 +48,36 @@ GwBoatItemBase::GwBoatItemBase(String name, String format, GwBoatItemBase::TOTyp this->type = 0; this->lastUpdateSource = -1; } -void GwBoatItemBase::setInvalidTime(unsigned long it, bool force){ - if (toType != TOType::user || force ){ - invalidTime=it; +void GwBoatItemBase::setInvalidTime(GwConfigHandler *cfg){ + if (toType != TOType::user){ + unsigned long timeout=GwBoatItemBase::INVALID_TIME; + switch(getToType()){ + case GwBoatItemBase::TOType::ais: + timeout=cfg->getInt(GwConfigDefinitions::timoAis); + break; + case GwBoatItemBase::TOType::def: + timeout=cfg->getInt(GwConfigDefinitions::timoDefault); + break; + case GwBoatItemBase::TOType::lng: + timeout=cfg->getInt(GwConfigDefinitions::timoLong); + break; + case GwBoatItemBase::TOType::sensor: + timeout=cfg->getInt(GwConfigDefinitions::timoSensor); + break; + case GwBoatItemBase::TOType::keep: + timeout=0; + break; + } + invalidTime=timeout; } } size_t GwBoatItemBase::getJsonSize() { return JSON_OBJECT_SIZE(10); } + +void GwBoatItemBase::GwBoatItemMap::add(const String &name,GwBoatItemBase *item){ + boatData->setInvalidTime(item); + (*this)[name]=item; +} + #define STRING_SIZE 40 GwBoatItemBase::StringWriter::StringWriter() { @@ -127,7 +151,7 @@ GwBoatItem<T>::GwBoatItem(String name, String formatInfo, unsigned long invalidT this->type = GwBoatItemTypes::getType(dummy); if (map) { - (*map)[name] = this; + map->add(name,this); } } template <class T> @@ -137,7 +161,7 @@ GwBoatItem<T>::GwBoatItem(String name, String formatInfo, GwBoatItemBase::TOType this->type = GwBoatItemTypes::getType(dummy); if (map) { - (*map)[name] = this; + map->add(name,this); } } @@ -322,28 +346,12 @@ void GwBoatDataSatList::toJsonDoc(GwJsonDocument *doc, unsigned long minTime) GwBoatData::GwBoatData(GwLog *logger, GwConfigHandler *cfg) { this->logger = logger; + this->config = cfg; +} +void GwBoatData::begin(){ for (auto &&it : values){ - unsigned long timeout=GwBoatItemBase::INVALID_TIME; - switch(it.second->getToType()){ - case GwBoatItemBase::TOType::ais: - timeout=cfg->getInt(GwConfigDefinitions::timoAis); - break; - case GwBoatItemBase::TOType::def: - timeout=cfg->getInt(GwConfigDefinitions::timoDefault); - break; - case GwBoatItemBase::TOType::lng: - timeout=cfg->getInt(GwConfigDefinitions::timoLong); - break; - case GwBoatItemBase::TOType::sensor: - timeout=cfg->getInt(GwConfigDefinitions::timoSensor); - break; - case GwBoatItemBase::TOType::keep: - timeout=0; - break; - } - it.second->setInvalidTime(timeout); + it.second->setInvalidTime(config); } - } GwBoatData::~GwBoatData() { @@ -456,6 +464,10 @@ double GwBoatData::getDoubleValue(String name, double defaultv) return defaultv; return it->second->getDoubleValue(); } + +void GwBoatData::setInvalidTime(GwBoatItemBase *item){ + if (config != nullptr) item->setInvalidTime(config); +} double formatCourse(double cv) { double rt = cv * 180.0 / M_PI; diff --git a/lib/boatData/GwBoatData.h b/lib/boatData/GwBoatData.h index 18a5f08..a4df3b4 100644 --- a/lib/boatData/GwBoatData.h +++ b/lib/boatData/GwBoatData.h @@ -14,6 +14,8 @@ #define ROT_WA_FACTOR 60 class GwJsonDocument; +class GwBoatData; + class GwBoatItemBase{ public: using TOType=enum{ @@ -56,7 +58,6 @@ class GwBoatItemBase{ GWSC(formatRot); GWSC(formatDate); GWSC(formatTime); - typedef std::map<String,GwBoatItemBase*> GwBoatItemMap; protected: int type; unsigned long lastSet=0; @@ -93,10 +94,15 @@ class GwBoatItemBase{ virtual double getDoubleValue()=0; String getName(){return name;} const String & getFormat() const{return format;} - virtual void setInvalidTime(unsigned long it, bool force=true); + virtual void setInvalidTime(GwConfigHandler *cfg); TOType getToType(){return toType;} + class GwBoatItemMap : public std::map<String,GwBoatItemBase*>{ + GwBoatData *boatData; + public: + GwBoatItemMap(GwBoatData *bd):boatData(bd){} + void add(const String &name,GwBoatItemBase *item); + }; }; -class GwBoatData; template<class T> class GwBoatItem : public GwBoatItemBase{ protected: T data; @@ -186,8 +192,9 @@ public: clazz *name=new clazz(#name,GwBoatItemBase::fmt,toType,&values) ; class GwBoatData{ private: - GwLog *logger; - GwBoatItemBase::GwBoatItemMap values; + GwLog *logger=nullptr; + GwConfigHandler *config=nullptr; + GwBoatItemBase::GwBoatItemMap values{this}; public: GWBOATDATA(double,COG,formatCourse) // course over ground @@ -231,9 +238,11 @@ class GwBoatData{ public: GwBoatData(GwLog *logger, GwConfigHandler *cfg); ~GwBoatData(); + void begin(); template<class T> GwBoatItem<T> *getOrCreate(T initial,GwBoatItemNameProvider *provider); template<class T> bool update(T value,int source,GwBoatItemNameProvider *provider); template<class T> T getDataWithDefault(T defaultv, GwBoatItemNameProvider *provider); + void setInvalidTime(GwBoatItemBase *item); bool isValid(String name); double getDoubleValue(String name,double defaultv); GwBoatItemBase *getBase(String name); diff --git a/src/main.cpp b/src/main.cpp index 714c0a5..90e5691 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -784,6 +784,7 @@ void setup() { logger.prefix="FALLBACK:"; logger.setWriter(new DefaultLogWriter()); #endif + boatData.begin(); userCodeHandler.startInitTasks(MIN_USER_TASK); channels.preinit(); config.stopChanges(); From a8a0df4b70c42e05ba0ac04b6e7317fe733d4947 Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Wed, 13 Nov 2024 18:07:50 +0100 Subject: [PATCH 51/58] #50: fix handling of GSV messages (completely wrong sat info) --- lib/nmea0183ton2k/NMEA0183DataToN2K.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/nmea0183ton2k/NMEA0183DataToN2K.cpp b/lib/nmea0183ton2k/NMEA0183DataToN2K.cpp index be01cac..bd593d5 100644 --- a/lib/nmea0183ton2k/NMEA0183DataToN2K.cpp +++ b/lib/nmea0183ton2k/NMEA0183DataToN2K.cpp @@ -860,7 +860,7 @@ private: LOG_DEBUG(GwLog::DEBUG,"GSV invalid current %u %s",current,msg.line); return; } - for (int idx=2;idx < msg.FieldCount();idx+=4){ + for (int idx=3;idx < msg.FieldCount();idx+=4){ if (msg.FieldLen(idx) < 1 || msg.FieldLen(idx+1) < 1 || msg.FieldLen(idx+2) < 1 || From f73390c9aef827e187e05d0a48aefeaaf8df1bfb Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Thu, 14 Nov 2024 15:20:13 +0100 Subject: [PATCH 52/58] enable channel if only seasmart or actisense is configured --- lib/channel/GwChannelList.cpp | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/channel/GwChannelList.cpp b/lib/channel/GwChannelList.cpp index 4371cef..7861f3d 100644 --- a/lib/channel/GwChannelList.cpp +++ b/lib/channel/GwChannelList.cpp @@ -369,17 +369,20 @@ static GwChannel * createChannel(GwLog *logger, GwConfigHandler *config, int id, return nullptr; } GwChannel *channel = new GwChannel(logger, param->name,param->id,param->maxId); + bool sendSeaSmart=config->getBool(param->sendSeasmart); + bool readAct=config->getBool(param->readAct); + bool writeAct=config->getBool(param->writeAct); channel->setImpl(impl); channel->begin( - canRead || canWrite, + canRead || canWrite || readAct || writeAct|| sendSeaSmart, canWrite, canRead, config->getString(param->readF), config->getString(param->writeF), - config->getBool(param->sendSeasmart), + sendSeaSmart, config->getBool(param->toN2K), - config->getBool(param->readAct), - config->getBool(param->writeAct)); + readAct, + writeAct); LOG_INFO("created channel %s",channel->toString().c_str()); return channel; } From 048a16d9391bf3a97910f03aa562c2335b697df8 Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Thu, 14 Nov 2024 16:16:40 +0100 Subject: [PATCH 53/58] add listeners for status handling --- web/index.js | 106 ++++++++++++++++++++++++++++----------------------- 1 file changed, 59 insertions(+), 47 deletions(-) diff --git a/web/index.js b/web/index.js index eabd250..538a51a 100644 --- a/web/index.js +++ b/web/index.js @@ -76,47 +76,49 @@ } getJson('/api/status') .then(function (jsonData) { + if (jsonData.salt !== undefined) { + lastSalt=jsonData.salt; + delete jsonData.salt; + } + if (jsonData.minUser !== undefined){ + minUser=jsonData.minUser; + delete jsonData.minUser; + } + callListeners(api.EVENTS.status,jsonData); let statusPage = document.getElementById('statusPageContent'); let even = true; //first counter - for (let k in jsonData) { - if (k == "salt") { - lastSalt = jsonData[k]; - continue; - } - if (k == "minUser") { - minUser = parseInt(jsonData[k]); - continue; - } - if (!statusPage) continue; - if (typeof (jsonData[k]) === 'object') { - if (k.indexOf('count') == 0) { - createCounterDisplay(statusPage, k.replace("count", "").replace(/in$/, " in").replace(/out$/, " out"), k, even); - even = !even; - for (let sk in jsonData[k]) { - let key = k + "." + sk; - if (typeof (jsonData[k][sk]) === 'object') { - //msg details - updateMsgDetails(key, jsonData[k][sk]); - } - else { - let el = document.getElementById(key); - if (el) el.textContent = jsonData[k][sk]; + if (statusPage){ + for (let k in jsonData) { + if (typeof (jsonData[k]) === 'object') { + if (k.indexOf('count') == 0) { + createCounterDisplay(statusPage, k.replace("count", "").replace(/in$/, " in").replace(/out$/, " out"), k, even); + even = !even; + for (let sk in jsonData[k]) { + let key = k + "." + sk; + if (typeof (jsonData[k][sk]) === 'object') { + //msg details + updateMsgDetails(key, jsonData[k][sk]); + } + else { + let el = document.getElementById(key); + if (el) el.textContent = jsonData[k][sk]; + } } } + if (k.indexOf("ch") == 0) { + //channel def + let name = k.substring(2); + channelList[name] = jsonData[k]; + } } - if (k.indexOf("ch") == 0) { - //channel def - let name = k.substring(2); - channelList[name] = jsonData[k]; + else { + let el = document.getElementById(k); + if (el) el.textContent = jsonData[k]; + forEl('.status-' + k, function (el) { + el.textContent = jsonData[k]; + }); } } - else { - let el = document.getElementById(k); - if (el) el.textContent = jsonData[k]; - forEl('.status-' + k, function (el) { - el.textContent = jsonData[k]; - }); - } } lastUpdate = (new Date()).getTime(); if (reloadConfig) { @@ -382,6 +384,7 @@ icon.classList.add('icon-less'); } }); + callListeners(api.EVENTS.counterDisplayCreated,row); } function validKey(key) { if (!key) return; @@ -1996,8 +1999,16 @@ hideDashboardItem(name); //will recreate it on next data receive } const api= { - registerListener: function (callback) { - listeners.push(callback); + registerListener: function (callback,opt_event) { + if (opt_event === undefined){ + listeners.push(callback); + } + else{ + listeners.push({ + event:opt_event, + callback:callback + }) + } }, /** * helper for creating dom elements @@ -2058,11 +2069,13 @@ parseBoatDataLine: parseBoatDataLine, EVENTS: { init: 0, //called when capabilities are loaded, data is capabilities - tab: 1, //tab page activated data is the id of the tab page - config: 2, //data is the config object - boatData: 3, //data is the list of boat Data items + tab: 1, //tab page activated, data is the id of the tab page + config: 2, //called when the config data is loaded,data is the config object + boatData: 3, //called when boatData is received, data is the list of boat Data items dataItemCreated: 4, //data is an object with // name: the item name, element: the frame item of the boat data display + status: 5, //status received, data is the status object + counterDisplayCreated: 6 //data is the row for the display } }; function callListeners(event,data){ @@ -2070,6 +2083,13 @@ if (typeof(listener) === 'function'){ listener(event,data); } + else if (typeof(listener) === 'object'){ + if (listener.event === event){ + if (typeof(listener.callback) === 'function'){ + listener.callback(event,data); + } + } + } }) } window.esp32nmea2k = api; @@ -2120,14 +2140,6 @@ }); } } catch (e) { } - let statusPage = document.getElementById('statusPageContent'); - /*if (statusPage){ - let even=true; - for (let c in counters){ - createCounterDisplay(statusPage,counters[c],c,even); - even=!even; - } - }*/ forEl('#uploadFile', function (el) { el.addEventListener('change', function (ev) { if (ev.target.files.length < 1) return; From 506dd7ea9f11d5876c68e8a42c1431ed8abb2c8d Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Thu, 14 Nov 2024 16:16:51 +0100 Subject: [PATCH 54/58] add example listener --- lib/exampletask/index.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/exampletask/index.js b/lib/exampletask/index.js index a85fe10..da098e0 100644 --- a/lib/exampletask/index.js +++ b/lib/exampletask/index.js @@ -11,6 +11,11 @@ const infoUrl='https://github.com/wellenvogel/esp32-nmea2000/tree/master/lib/exampletask'; let boatItemName; let boatItemElement; + api.registerListener((id,data)=>{ + if (isActive){ + console.log("exampletask status listener",data); + } + },api.EVENTS.status) api.registerListener((id,data)=>{ if (id === api.EVENTS.init){ //data is capabilities From 538f643fbffb83db65c5cc52bf5c3a7cf855c9ed Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Thu, 14 Nov 2024 18:15:12 +0100 Subject: [PATCH 55/58] add registerRequestHandler to the API with examples --- lib/api/GwApi.h | 16 +++++++++++++ lib/exampletask/GwExampleTask.cpp | 37 +++++++++++++++++++++++++++++++ lib/exampletask/Readme.md | 20 +++++++++++++++++ lib/exampletask/index.js | 15 +++++++++++++ lib/gwwebserver/GwWebServer.h | 3 ++- lib/queue/GwSynchronized.h | 4 ++-- lib/usercode/GwUserCode.cpp | 30 +++++++++++++++++++++++++ lib/usercode/GwUserCode.h | 2 ++ src/main.cpp | 10 ++++++++- 9 files changed, 133 insertions(+), 4 deletions(-) diff --git a/lib/api/GwApi.h b/lib/api/GwApi.h index 5a24e92..9d79ef3 100644 --- a/lib/api/GwApi.h +++ b/lib/api/GwApi.h @@ -6,7 +6,9 @@ #include "GWConfig.h" #include "GwBoatData.h" #include "GwXDRMappings.h" +#include "GwSynchronized.h" #include <map> +#include <ESPAsyncWebServer.h> class GwApi; typedef void (*GwUserTaskFunction)(GwApi *); //API to be used for additional tasks @@ -171,6 +173,20 @@ class GwApi{ virtual void remove(int idx){} virtual TaskInterfaces * taskInterfaces()=0; + /** + * register handler for web URLs + * Please be aware that this handler function will always be called from a separate + * task. So you must ensure proper synchronization! + */ + using HandlerFunction=std::function<void(AsyncWebServerRequest *)>; + /** + * @param url: the url of that will trigger the handler. + * it will be prefixed with /api/user/<taskname> + * taskname is the name that you used in addUserTask + * @param handler: the handler function (see remark above about thread synchronization) + */ + virtual void registerRequestHandler(const String &url,HandlerFunction handler)=0; + /** * only allowed during init methods */ diff --git a/lib/exampletask/GwExampleTask.cpp b/lib/exampletask/GwExampleTask.cpp index 340b785..9b4cbcd 100644 --- a/lib/exampletask/GwExampleTask.cpp +++ b/lib/exampletask/GwExampleTask.cpp @@ -7,6 +7,7 @@ #include <vector> #include "N2kMessages.h" #include "GwXdrTypeMappings.h" + /** * INVALID!!! - the next interface declaration will not work * as it is not in the correct header file @@ -144,6 +145,26 @@ String formatValue(GwApi::BoatValue *value){ return String(buffer); } +class ExampleWebData{ + SemaphoreHandle_t lock; + int data=0; + public: + ExampleWebData(){ + lock=xSemaphoreCreateMutex(); + } + ~ExampleWebData(){ + vSemaphoreDelete(lock); + } + void set(int v){ + GWSYNCHRONIZED(&lock); + data=v; + } + int get(){ + GWSYNCHRONIZED(&lock); + return data; + } +}; + void exampleTask(GwApi *api){ GwLog *logger=api->getLogger(); //get some configuration data @@ -172,8 +193,24 @@ void exampleTask(GwApi *api){ LOG_DEBUG(GwLog::LOG,"exampleNotWorking update returned %d",(int)nwrs); String voltageTransducer=api->getConfig()->getString(GwConfigDefinitions::exTransducer); int voltageInstance=api->getConfig()->getInt(GwConfigDefinitions::exInstanceId); + ExampleWebData webData; + /** + * an example web request handler + * it uses a synchronized data structure as it gets called from a different thread + * be aware that you must not block for longer times here! + */ + api->registerRequestHandler("data",[&webData](AsyncWebServerRequest *request){ + int data=webData.get(); + char buffer[30]; + snprintf(buffer,29,"%d",data); + buffer[29]=0; + request->send(200,"text/plain",buffer); + }); + int loopcounter=0; while(true){ delay(1000); + loopcounter++; + webData.set(loopcounter); /* * getting values from the internal data store (boatData) requires some special handling * our tasks runs (potentially) at some time on a different core then the main code diff --git a/lib/exampletask/Readme.md b/lib/exampletask/Readme.md index 965e177..2a7e42e 100644 --- a/lib/exampletask/Readme.md +++ b/lib/exampletask/Readme.md @@ -32,6 +32,26 @@ Files This file allows to add some config definitions that are needed for our task. For the possible options have a look at the global [config.json](../../web/config.json). Be careful not to overwrite config defitions from the global file. A good practice wood be to prefix the names of definitions with parts of the library name. Always put them in a separate category so that they do not interfere with the system ones. The defined config items can later be accessed in the code (see the example in [GwExampleTask.cpp](GwExampleTask.cpp)). + * [index.js](index.js)<br> + You can add javascript code that will contribute to the UI of the system. The WebUI provides a small API that allows you to "hook" into some functions to include your own parts of the UI. This includes adding new tabs, modifying/replacing the data display items, modifying the status display or accessing the config items. + For the API refer to [../../web/index.js](../../web/index.js#L2001). + To start interacting just register for some events like api.EVENTS.init. You can check the capabilities you have defined to see if your task is active. + By registering an own formatter [api.addUserFormatter](../../web/index.js#L2054) you can influence the way boat data items are shown. + You can even go for an own display by registering for the event *dataItemCreated* and replace the dom element content with your own html. By additionally having added a user formatter you can now fill your own html with the current value. + By using [api.addTabPage](../../web/index.js#L2046) you can add new tabs that you can populate with your own code. Or you can link to an external URL.<br> + Please be aware that your js code is always combined with the code from the core into one js file.<br> + For fast testing there is a small python script that allow you to test the UI without always flushing each change. + Just run it with + ``` + tools/testServer.py nnn http://x.x.x.x/api + ``` + with nnn being the local port and x.x.x.x the address of a running system. Open `http://localhost:nnn` in your browser.<br> + After a change just start the compilation and reload the page. + + * [index.css](index.css)<br> + You can add own css to influence the styling of the display. + + Interfaces ---------- The task init function and the task function interact with the core using an [API](../api/GwApi.h) that they get when started. diff --git a/lib/exampletask/index.js b/lib/exampletask/index.js index da098e0..155991c 100644 --- a/lib/exampletask/index.js +++ b/lib/exampletask/index.js @@ -28,6 +28,21 @@ //you can use the helper addEl to create elements let page=api.addTabPage(tabName,"Example"); api.addEl('div','hdg',page,"this is a test tab"); + let vrow=api.addEl('div','row',page); + api.addEl('span','label',vrow,'loops: '); + let lcount=api.addEl('span','value',vrow,'0'); + //query the loop count + window.setInterval(()=>{ + fetch('/api/user/exampleTask/data') + .then((res)=>{ + if (! res.ok) throw Error("server error: "+res.status); + return res.text(); + }) + .then((txt)=>{ + lcount.textContent=txt; + }) + .catch((e)=>console.log("rq:",e)); + },1000); api.addEl('button','',page,'Info').addEventListener('click',function(ev){ window.open(infoUrl,'info'); }) diff --git a/lib/gwwebserver/GwWebServer.h b/lib/gwwebserver/GwWebServer.h index 84c265a..c2b48d8 100644 --- a/lib/gwwebserver/GwWebServer.h +++ b/lib/gwwebserver/GwWebServer.h @@ -4,6 +4,7 @@ #include <functional> #include "GwMessage.h" #include "GwLog.h" +#include "GwApi.h" class GwWebServer{ private: AsyncWebServer *server; @@ -11,7 +12,7 @@ class GwWebServer{ GwLog *logger; public: typedef GwRequestMessage *(RequestCreator)(AsyncWebServerRequest *request); - using HandlerFunction=std::function<void(AsyncWebServerRequest *)>; + using HandlerFunction=GwApi::HandlerFunction; GwWebServer(GwLog *logger, GwRequestQueue *queue,int port); ~GwWebServer(); void begin(); diff --git a/lib/queue/GwSynchronized.h b/lib/queue/GwSynchronized.h index 53241db..786b5f0 100644 --- a/lib/queue/GwSynchronized.h +++ b/lib/queue/GwSynchronized.h @@ -7,10 +7,10 @@ class GwSynchronized{ public: GwSynchronized(SemaphoreHandle_t *locker){ this->locker=locker; - xSemaphoreTake(*locker, portMAX_DELAY); + if (locker != nullptr) xSemaphoreTake(*locker, portMAX_DELAY); } ~GwSynchronized(){ - xSemaphoreGive(*locker); + if (locker != nullptr) xSemaphoreGive(*locker); } }; diff --git a/lib/usercode/GwUserCode.cpp b/lib/usercode/GwUserCode.cpp index 4522c92..211eda9 100644 --- a/lib/usercode/GwUserCode.cpp +++ b/lib/usercode/GwUserCode.cpp @@ -191,6 +191,7 @@ class TaskApi : public GwApiInternal SemaphoreHandle_t *mainLock; SemaphoreHandle_t localLock; std::map<int,GwCounter<String>> counter; + std::map<String,GwApi::HandlerFunction> webHandlers; String name; bool counterUsed=false; int counterIdx=0; @@ -315,6 +316,10 @@ public: virtual bool addXdrMapping(const GwXDRMappingDef &def){ return api->addXdrMapping(def); } + virtual void registerRequestHandler(const String &url,HandlerFunction handler){ + GWSYNCHRONIZED(&localLock); + webHandlers[url]=handler; + } virtual void addCapability(const String &name, const String &value){ if (! isInit) return; userCapabilities[name]=value; @@ -335,6 +340,16 @@ public: virtual void setCalibrationValue(const String &name, double value){ api->setCalibrationValue(name,value); } + virtual bool handleWebRequest(const String &url,AsyncWebServerRequest *req){ + GWSYNCHRONIZED(&localLock); + auto it=webHandlers.find(url); + if (it == webHandlers.end()){ + api->getLogger()->logDebug(GwLog::LOG,"no web handler task=%s url=%s",name.c_str(),url.c_str()); + return false; + } + it->second(req); + return true; + } }; @@ -404,4 +419,19 @@ int GwUserCode::getJsonSize(){ } } return rt; +} +void GwUserCode::handleWebRequest(const String &url,AsyncWebServerRequest *req){ + int sep1=url.indexOf('/'); + String tname; + if (sep1 > 0){ + tname=url.substring(0,sep1); + for (auto &&it:userTasks){ + if (it.api && it.name == tname){ + if (it.api->handleWebRequest(url.substring(sep1+1),req)) return; + break; + } + } + } + LOG_DEBUG(GwLog::DEBUG,"no task found for web request %s[%s]",url.c_str(),tname.c_str()); + req->send(404, "text/plain", "not found"); } \ No newline at end of file diff --git a/lib/usercode/GwUserCode.h b/lib/usercode/GwUserCode.h index a218bc9..94e745d 100644 --- a/lib/usercode/GwUserCode.h +++ b/lib/usercode/GwUserCode.h @@ -11,6 +11,7 @@ class GwApiInternal : public GwApi{ ~GwApiInternal(){} virtual void fillStatus(GwJsonDocument &status){}; virtual int getJsonSize(){return 0;}; + virtual bool handleWebRequest(const String &url,AsyncWebServerRequest *req){return false;} }; class GwUserTask{ public: @@ -50,5 +51,6 @@ class GwUserCode{ Capabilities *getCapabilities(); void fillStatus(GwJsonDocument &status); int getJsonSize(); + void handleWebRequest(const String &url,AsyncWebServerRequest *); }; #endif \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp index 90e5691..ae8f3d5 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -348,6 +348,8 @@ public: } return xdrMappings.addFixedMapping(mapping); } + virtual void registerRequestHandler(const String &url,HandlerFunction handler){ + } virtual void addCapability(const String &name, const String &value){} virtual bool addUserTask(GwUserTaskFunction task,const String Name, int stackSize=2000){ return false; @@ -768,6 +770,7 @@ void loopFunction(void *){ //delay(1); } } +const String USERPREFIX="/api/user/"; void setup() { mainLock=xSemaphoreCreateMutex(); uint8_t chipid[6]; @@ -845,7 +848,12 @@ void setup() { snprintf(buffer,29,"%g",value); buffer[29]=0; request->send(200,"text/plain",buffer); - }); + }); + webserver.registerHandler((USERPREFIX+"*").c_str(),[&USERPREFIX](AsyncWebServerRequest *req){ + String turl=req->url().substring(USERPREFIX.length()); + logger.logDebug(GwLog::DEBUG,"user web request for %s",turl.c_str()); + userCodeHandler.handleWebRequest(turl,req); + }); webserver.begin(); xdrMappings.begin(); From c292b82635755acd5bffa9c17d339e6a32b67729 Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Thu, 14 Nov 2024 18:33:18 +0100 Subject: [PATCH 56/58] correct some js errors in example --- lib/exampletask/index.js | 173 +++++++++++++++++++-------------------- 1 file changed, 83 insertions(+), 90 deletions(-) diff --git a/lib/exampletask/index.js b/lib/exampletask/index.js index 155991c..b2c6f9d 100644 --- a/lib/exampletask/index.js +++ b/lib/exampletask/index.js @@ -5,104 +5,97 @@ //on our case this is "testboard" //so we only start any action when we receive the init event //and we successfully checked that our requested capability is there - let isActive=false; const tabName="example"; const configName="exampleBDSel"; const infoUrl='https://github.com/wellenvogel/esp32-nmea2000/tree/master/lib/exampletask'; let boatItemName; let boatItemElement; - api.registerListener((id,data)=>{ - if (isActive){ - console.log("exampletask status listener",data); - } - },api.EVENTS.status) - api.registerListener((id,data)=>{ - if (id === api.EVENTS.init){ - //data is capabilities - //check if our requested capability is there (see GwExampleTask.h) - if (data.testboard) isActive=true; - if (isActive){ - //add a simple additional tab page - //you will have to build the content of the page dynamically - //using normal dom manipulation methods - //you can use the helper addEl to create elements - let page=api.addTabPage(tabName,"Example"); - api.addEl('div','hdg',page,"this is a test tab"); - let vrow=api.addEl('div','row',page); - api.addEl('span','label',vrow,'loops: '); - let lcount=api.addEl('span','value',vrow,'0'); - //query the loop count - window.setInterval(()=>{ - fetch('/api/user/exampleTask/data') - .then((res)=>{ - if (! res.ok) throw Error("server error: "+res.status); - return res.text(); - }) - .then((txt)=>{ - lcount.textContent=txt; - }) - .catch((e)=>console.log("rq:",e)); - },1000); - api.addEl('button','',page,'Info').addEventListener('click',function(ev){ - window.open(infoUrl,'info'); + api.registerListener((id, data) => { + //data is capabilities + //check if our requested capability is there (see GwExampleTask.h) + if (!data.testboard) return; //do nothing if we are not active + //add a simple additional tab page + //you will have to build the content of the page dynamically + //using normal dom manipulation methods + //you can use the helper addEl to create elements + let page = api.addTabPage(tabName, "Example"); + api.addEl('div', 'hdg', page, "this is a test tab"); + let vrow = api.addEl('div', 'row', page); + api.addEl('span', 'label', vrow, 'loops: '); + let lcount = api.addEl('span', 'value', vrow, '0'); + //query the loop count + window.setInterval(() => { + fetch('/api/user/exampleTask/data') + .then((res) => { + if (!res.ok) throw Error("server error: " + res.status); + return res.text(); }) - //add a tab for an external URL - api.addTabPage('exhelp','Info',infoUrl); + .then((txt) => { + //set the text content of our value element with what we received + lcount.textContent = txt; + }) + .catch((e) => console.log("rq:", e)); + }, 1000); + api.addEl('button', '', page, 'Info').addEventListener('click', function (ev) { + window.open(infoUrl, 'info'); + }) + //add a tab for an external URL + api.addTabPage('exhelp', 'Info', infoUrl); + //now as we know we are active - register all the listeners we need + api.registerListener((id, data) => { + console.log("exampletask status listener", data); + }, api.EVENTS.status) + api.registerListener((id, data) => { + if (data === tabName) { + //maybe we need some activity when our page is being activated + console.log("example tab activated"); } - } - if (isActive){ - //console.log("exampletask listener",id,data); - if (id === api.EVENTS.tab){ - if (data === tabName){ - //maybe we need some activity when our page is being activated - console.log("example tab activated"); - } + }, api.EVENTS.tab); + + api.registerListener((id, data) => { + //we have a configuration that + //gives us the name of a boat data item we would like to + //handle special + //in our case we just use an own formatter and add some + //css to the display field + //as this item can change we need to keep track of the + //last item we handled + let nextboatItemName = data[configName]; + console.log("value of " + configName, nextboatItemName); + if (nextboatItemName) { + //register a user formatter that will be called whenever + //there is a new valid value + //we simply add an "X:" in front + api.addUserFormatter(nextboatItemName, "m(x)", function (v, valid) { + if (!valid) return; + return "X:" + v; + }) + //after this call the item will be recreated } - if (id == api.EVENTS.config){ - //we have a configuration that - //gives us the name of a boat data item we would like to - //handle special - //in our case we just use an own formatter and add some - //css to the display field - //as this item can change we need to keep track of the - //last item we handled - let nextboatItemName=data[configName]; - console.log("value of "+configName,nextboatItemName); - if (nextboatItemName){ - //register a user formatter that will be called whenever - //there is a new valid value - //we simply add an "X:" in front - api.addUserFormatter(nextboatItemName,"m(x)",function(v,valid){ - if (!valid) return; - return "X:"+v; - }) - //after this call the item will be recreated - } - if (boatItemName !== undefined && boatItemName != nextboatItemName){ - //if the boat item that we handle has changed, remove - //the previous user formatter (this will recreate the item) - api.removeUserFormatter(boatItemName); - } - boatItemName=nextboatItemName; - boatItemElement=undefined; + if (boatItemName !== undefined && boatItemName != nextboatItemName) { + //if the boat item that we handle has changed, remove + //the previous user formatter (this will recreate the item) + api.removeUserFormatter(boatItemName); } - if (id == api.EVENTS.dataItemCreated){ - //this event is called whenever a data item has - //been created (or recreated) - //if this is the item we handle, we just add a css class - //we could also completely rebuild the dom below the element - //and use our formatter to directly write/draw the data - //avoid direct manipulation of the element (i.e. changing the classlist) - //as this element remains there all the time - if (boatItemName && boatItemName == data.name){ - boatItemElement=data.element; - //use the helper forEl to find elements within the dashboard item - //the value element has the class "dashValue" - api.forEl(".dashValue",function(el){ - el.classList.add("examplecss"); - },boatItemElement); - } + boatItemName = nextboatItemName; + boatItemElement = undefined; + }, api.EVENTS.config); + api.registerListener((id, data) => { + //this event is called whenever a data item has + //been created (or recreated) + //if this is the item we handle, we just add a css class + //we could also completely rebuild the dom below the element + //and use our formatter to directly write/draw the data + //avoid direct manipulation of the element (i.e. changing the classlist) + //as this element remains there all the time + if (boatItemName && boatItemName == data.name) { + boatItemElement = data.element; + //use the helper forEl to find elements within the dashboard item + //the value element has the class "dashValue" + api.forEl(".dashValue", function (el) { + el.classList.add("examplecss"); + }, boatItemElement); } - } - }) + }, api.EVENTS.dataItemCreated); + }, api.EVENTS.init); })(); From 13efceeef703ea1d6ee32ddf48771428f8a63db2 Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Thu, 14 Nov 2024 19:47:10 +0100 Subject: [PATCH 57/58] extend doc for new api functions and networking options --- Readme.md | 5 ++- doc/network.md | 80 +++++++++++++++++++++++++++++++++++++++ lib/exampletask/Readme.md | 3 +- 3 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 doc/network.md diff --git a/Readme.md b/Readme.md index 6de6c6a..ebcd08b 100644 --- a/Readme.md +++ b/Readme.md @@ -47,10 +47,13 @@ Hardware -------- The software is prepared to run on different kinds of ESP32 based modules and accessoirs. For some of them prebuild binaries are available that only need to be flashed, others would require to add some definitions of the used PINs and features and to build the binary. For the list of hardware set ups refer to [Hardware](doc/Hardware.md). -For details of the usage of serial devices and the USB connection refer to [Serial and USB](doc/serial-usb.md). There is a couple of prebuild binaries that you can directly flash to your device. For other combinations of hardware there is an [online build service](doc/BuildService.md) that will allow you to select your hardware and trigger a build. +Connectivity +------------ +For details of the usage of serial devices and the USB connection refer to [Serial and USB](doc/serial-usb.md).<br> +For details on the networking capabilities refer to [Networking](doc/network.md). Installation ------------ diff --git a/doc/network.md b/doc/network.md new file mode 100644 index 0000000..6c92692 --- /dev/null +++ b/doc/network.md @@ -0,0 +1,80 @@ +Networking +========== +The gateway includes a couple of network functions to send and receive NMEA data on network connections and to use a WebBrowser for configuration and status display. + +To understand the networking functions you always have to consider 2 parts: + +The "physical" connection +------------------------- +For the gateway this is always a Wifi connection. +It provides the ability to use it as an access point and/or to connect to another wifi network (wifi client). + +Access Point +************ +When starting up it will create an own Wifi network (access point or AP) with the name of the system. You connect to this network like to any Wifi Hotspot. +The initial password is esp32nmea2k. You should change this password on the configuration page in the Web UI. +When you connect the gateway will provide you with an IP address (DHCP) - and it will also have an own IP address in this range (default: 192.168.15.1). If this IP address (range) conflicts with other devices (especially if you run multiple gateways and connect them) you can change the range at the system tab on the configuration page.<br> +If you do not need the access point during normal operation you can set "stopApTime" so that it will shut down after this time (in minutes) if no device is connected. This will also reduce the power consumption. + +Wifi Client +*********** +Beside the own access point the gateway can also connect to another Wifi network. You need to configure the SSID and password at the "wifi client" tab. +On the status page you can see if the gateway is connected and which address it got within the network. + +You can use both networks to connect to the gateway. It announces it's services via [bonjour](https://developer.apple.com/bonjour/). So you normally should be able to reach your system using Name.local (name being the system name, default ESP32NMEA2K). Or you can use an app that allows for bonjour browsing. + +The "logical" connection +------------------------ +After you connected a device to the gateway on either the access point or by using the same Wifi network you can easily access the Web UI with a browser - e.g. using the Name.local url. + +To send or receive NMEA data you additionally need to configure some connection between the gateway and your device(s). +The gateway provides the following options for NMEA connections: + +TCP Server +********** +When using the TCP server you must configure a connection to the gateway _on the device_ that you would like to connect. The gateway listens at port 10110 (can be changed at the TCP server tab). So on your device you need to configure the address of the gateway and this port. The address depends on the network that you use to connect your device - either the address from the gateway access point (default: 192.168.15.1) - or the address that the gateway was given in the Wifi client network (check this on the status page).<br> +If your device supports this you can also use the Name.local for the address - or let the device browse for a bonjour service.<br> +The TCP server has a limit for the number of connections (can be configured, default: 6). As for any channel you can define what it will write and read on it's connections and apply filters. +If you want to send NMEA2000 data via TCP you can enable "Seasmart out". + +TCP Client +********** +With this settings you can configure the gateway to establish a connection to another device that provides data via TCP. You need to know the address and port for that device. If the other device also uses bonjour (like e.g. a second gateway device) you can also use this name instead of the address. +Like for the TCP server you can define what should be send or received with filters. + +UDP Reader +********** +UDP is distinct from TCP in that it does not establish a connection directly. Instead in sends/receives messages - without checking if they have been received by someone at all. Therefore it is also not able to ensure that really all messages are reaching their destination. But as the used protocols (NMEA0183, NMEA2000) are prepared for unreliable connections any way (for serial connections you normally have no idea if the data arrives) UDP is still a valid way of connecting devices.<br> +One advantage of UDP is that you can send out messages as broadcast or multicast - thus allowing for multiple receivers. + +Small hint for __multicast__:<br> +Normally in the environment the gateway will work you will not use multicast. If you want to send to multiple devices you will use broadcast. The major difference between them are 2 points:<br> + 1. broadcast messages will not pass a real router (but they will be available to all devices connected to one access point) + 2. broadcast messages will always be send to all devices - regardless whether they would like to receive them or not. This can create a bit more network traffic. + +Multicast requires that receivers announce their interest in receiving messages (by "joining" a so called multicast group) and this way only interested devices will receive such messages - and it is possible to configure routers in a way that they route multicast messages. + +To use the gateway UDP reader you must select from where you would like to receive messages. In any case you need to set up a port (default: 10110). Afterwards you need to decide on the allowed sources: + * _all_ (default): accept messages from both the access point and the wifi client network - both broadcast messages and directly addressed ones + * _ap_: only accept messages from devices that are connected to the access point + * _cli_: only accept messages from devices on the Wifi client network + * _mp-all_: you need to configure the multicast address(group) you would like to join and will receive multicast from both the access point and the wifi client network + * _mp-ap_: multicast only from the access point network + * _mp-cli_: multicast only from the wifi client network + +UDP Writer +********** +The UDP writer basically is the counterpart for the UDP reader. +You also have to select where do you want the UDP messages to be sent to. + * _bc-all_ (default): Send the messages as broadcast to devices connected to the own access point and to devices on the wifi client network + * _bc-ap_: send the messages as broadcast only to the access point network + * _bc-cli_: send the messages as broadcast to the wifi client network + * _normal_: you need to configure a destination address (one device) that you want the messages to be send to + * _mc-all_: send messages as multicast to both the access point network and the wifi client network. _Hint_: Only devices that configured the same multicast address will receive such messages. + * _mc-ap_: multicast only to the access point network + * _mc-cli_: multicast only to the wifi client network. + +With the combination of UDP writer and UDP reader you can easily connect multiple gateway devices without a lot of configuration. Just configure one device as UDP writer (with the default settings) and configure other devices as UDP reader (also with default settings) - this way it does not matter how you connect the devices - all devices will receive the data that is sent by the first one.<br> +__Remark:__ be carefull not to create loops when you would like to send data in both directions between 2 devices using UDP. Either use filters - or use TCP connections as they are able to send data in both directions on one connection (without creating a loop). + +If you want to forward NMEA2000 data from one gateway device to another, just enable "Seasmart out" at the sender side. This will encapsulate the NMEA2000 data in a NMEA0183 compatible format. The receiver will always automatically detect this format and handle the data correctly. diff --git a/lib/exampletask/Readme.md b/lib/exampletask/Readme.md index 2a7e42e..4c51d92 100644 --- a/lib/exampletask/Readme.md +++ b/lib/exampletask/Readme.md @@ -70,7 +70,8 @@ Files * add capabilities (since 20231105 - as an alternative to a static DECLARE_CAPABILITY ) * add a user task (since 20231105 - as an alternative to a static DECLARE_USERTASK) * store or read task interface data (see below) - + * add a request handler for web requests (since 202411xx) - see registerRequestHandler in the API + __Interfacing between Task__ From 5adf48322088205a42584780680c3e3584e5c435 Mon Sep 17 00:00:00 2001 From: andreas <andreas@wellenvogel.de> Date: Thu, 14 Nov 2024 20:01:51 +0100 Subject: [PATCH 58/58] add release doc 20241114 --- Readme.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/Readme.md b/Readme.md index ebcd08b..08ac3e3 100644 --- a/Readme.md +++ b/Readme.md @@ -170,6 +170,28 @@ For details refer to the [example description](lib/exampletask/Readme.md). Changelog --------- +[20241114](../../releases/tag/20241114) +********** +* UDP writer and reader - [#79](../../issues/79) +* extensions for [user tasks](lib/exampletask/Readme.md) + * extend the Web UI with js and css + * register handler for Web Requests +* Naming of the config file [#87](../../issues/87) +* MTW from PGN130311 [#83](../../issues/83) +* USB connection on S3 stops [#81](../../issues/81) +* remove invalid true wind calc, allow to configure some mapping - partly [#75](../../issues/75) +* correctly parse GSV messages [#50](../../issues/50) +* minor adaptations from new version [#66](../../issues/66) +* new platform version 6.8.1 +* internally restructure the channel handling +* add docs for [networking](doc/network.md) and [serial/USB](doc/serial-usb.md) +* allow to configure the timeout(s) for the data display +* new library versions - nmea2000 4.22.0, nmea0183 1.10.1 +* allow for builds completely without FastLED +* wipe the nvs partition on factory reset (to handle corrupted config) +* do not store the wifi settings in nvs on the system level [#78](../../issues/78) +* rename of data: HDG->HDT, MHDG->HDM +* adapt crash decoder tool to s3 [20240428](../../releases/tag/20240428) ********** * fix build error with M5 gps kit