Keep the colleague's microbu-esp32c5 tree in this repository

obu-firmware builds against vanetza-idf from microbu-esp32c5/external, but
that tree was gitignored, so a clone of this repository could not build the
firmware it ships. It is now committed here as ordinary files in its own
folder, microbu-esp32c5/: the colleague's commit cf4b99f plus the V2X2MAP
bridge's signature verification (--trust) used on the bench. Nothing is
fetched from or pushed to the colleague's repository; this repository and
its remotes carry everything. The folder's own .gitignore keeps build output,
downloaded components and private key material out, as it did there; the
committed file set is identical to that repository's tracked files.

The ESP32-C5 is still flashed from obu-firmware/, which only takes
vanetza-idf from microbu-esp32c5/, so the two stay separate folders.
FLASHING.md says how to take a newer version of the colleague's tree (copy
it over the folder, rebuild, test, commit).
This commit is contained in:
Ashin Walpola
2026-09-23 17:46:40 +02:00
parent 2f60623e18
commit 0e9525162d
9881 changed files with 1582523 additions and 17 deletions
@@ -0,0 +1,50 @@
These ASN.1 files are taken from their respective standardisation documents and are **not** licensed the same way as Vanetza itself.
Please refer to the referenced standardisation documents for details about the applicable Intellectual Property Rights (IPR).
To the best of my knowledge, ETSI does not object to bundling ASN.1 files of their published documents along with software.
Some editorial changes may have been applied, e.g. removal of trailing whitespace etc.
Code generated from the files located here can be found in the *vanetza/asn1/its* directory.
# Standardisation documents
- *EN302637-2v141-CAM.asn* from ETSI EN 302 637-2 v1.4.1 (2019-04)
- *EN302637-3v131-DENM.asn* from ETSI EN 302 637-3 v1.3.1 (2019-04)
- *TS102894-2v131-CDD.asn* from ETSI TS 102 894-2 v1.3.1 (2018-08)
- *TS102941v131-\*.asn* from ETSI TS 102 941 v1.3.1 (2019-02)
- *TS103097v131.asn* from ETSI TS 103 097 v1.3.1 (2017-10)
- *TS103097v211-\*.asn* from ETSI TS 103 097 v2.1.1 (2021-10)
- *TR103562v211.asn* from ETSI TR 103 562 v2.1.1 (2019-12)
- *IEEE1609dot2.asn* and *IEEE1609dot2BaseTypes.asn* from IEEE 1609.2 as printed in ETSI TS 103 097 v1.3.1
# ETSI WebSVN
You may find these ITS ASN.1 modules also in ETSI's public SVN repository: http://oldforge.etsi.org/websvn/listing.php?repname=ITS.ITS_ASN1
While above source is still online (in March 2020), ETSI seems to have moved to https://forge.etsi.org/rep/ITS/ITS_ASN1 by now.
This newer source also contains a license file explicitly. A copy is included below for reference.
> Copyright 2019 ETSI
>
> Redistribution and use in source and binary forms, with or without
> modification, are permitted provided that the following conditions are met:
> 1. Redistributions of source code must retain the above copyright notice,
> this list of conditions and the following disclaimer.
> 2. Redistributions in binary form must reproduce the above copyright notice,
> this list of conditions and the following disclaimer in the documentation
> and/or other materials provided with the distribution.
> 3. Neither the name of the copyright holder nor the names of its contributors
> may be used to endorse or promote products derived from this software without
> specific prior written permission.
>
> THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
> ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
> WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
> IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
> INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
> BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
> DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
> LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
> OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
> OF THE POSSIBILITY OF SUCH DAMAGE.
IEEE1609dot2.asn and IEEE1609dot2BaseTypes.asn (IEEE Std 1609.2 as printed in ETSI TS 103 097 v1.3.1) are copied from vanetza-idf/asn1 for verify.py; same terms as above.
@@ -0,0 +1,306 @@
IEEE1609dot2 {
iso(1) identified-organization(3) ieee(111) standards-association-numbered-series-standards(2) wave-stds(1609) dot2(2) base (1) schema (1) major-version-2(2)
}
--******************************************************************************
--
-- IEEE P1609.2 Data Types
--
--******************************************************************************
DEFINITIONS AUTOMATIC TAGS ::= BEGIN
EXPORTS ALL;
IMPORTS
CrlSeries,
EccP256CurvePoint,
EciesP256EncryptedKey,
EncryptionKey,
GeographicRegion,
GroupLinkageValue,
HashAlgorithm,
HashedId3,
HashedId8,
Hostname,
IValue,
LinkageValue,
Opaque,
Psid,
PsidSsp,
PsidSspRange,
PublicEncryptionKey,
PublicVerificationKey,
SequenceOfHashedId3,
SequenceOfPsidSsp,
SequenceOfPsidSspRange,
ServiceSpecificPermissions,
Signature,
SubjectAssurance,
SymmetricEncryptionKey,
ThreeDLocation,
Time64,
Uint3,
Uint8,
Uint16,
Uint32,
ValidityPeriod
FROM IEEE1609dot2BaseTypes {
iso(1) identified-organization(3) ieee(111) standards-association-numbered-series-standards(2) wave-stds(1609) dot2(2) base(1) base-types(2) major-version-2 (2)
};
--
--*********************************************************************
--
-- Structures for describing secured data
--
--*********************************************************************
-- Necessary to get certain tools to generate sample PDUs
-- TestIeee1609Dot2Data ::= Ieee1609Dot2Data
-- TestCertificate ::= Certificate
-- this structure belongs later in the file but putting it here avoids
-- compiler errors with certain tools
SignedDataPayload ::= SEQUENCE {
data Ieee1609Dot2Data OPTIONAL,
extDataHash HashedData OPTIONAL,
...
}
(WITH COMPONENTS {..., data PRESENT} |
WITH COMPONENTS {..., extDataHash PRESENT})
Ieee1609Dot2Data ::= SEQUENCE {
protocolVersion Uint8(3),
content Ieee1609Dot2Content
}
Ieee1609Dot2Content ::= CHOICE {
unsecuredData Opaque,
signedData SignedData,
encryptedData EncryptedData,
signedCertificateRequest Opaque,
...
}
SignedData ::= SEQUENCE {
hashId HashAlgorithm,
tbsData ToBeSignedData,
signer SignerIdentifier,
signature Signature
}
SignerIdentifier ::= CHOICE {
digest HashedId8,
certificate SequenceOfCertificate,
self NULL,
...
}
ToBeSignedData ::= SEQUENCE {
payload SignedDataPayload,
headerInfo HeaderInfo
}
HashedData::= CHOICE {
sha256HashedData OCTET STRING (SIZE(32)),
...
}
HeaderInfo ::= SEQUENCE {
psid Psid,
generationTime Time64 OPTIONAL,
expiryTime Time64 OPTIONAL,
generationLocation ThreeDLocation OPTIONAL,
p2pcdLearningRequest HashedId3 OPTIONAL,
missingCrlIdentifier MissingCrlIdentifier OPTIONAL,
encryptionKey EncryptionKey OPTIONAL,
...,
inlineP2pcdRequest SequenceOfHashedId3 OPTIONAL,
requestedCertificate Certificate OPTIONAL
}
MissingCrlIdentifier ::= SEQUENCE {
cracaId HashedId3,
crlSeries CrlSeries,
...
}
Countersignature ::= Ieee1609Dot2Data (WITH COMPONENTS {...,
content (WITH COMPONENTS {...,
signedData (WITH COMPONENTS {...,
tbsData (WITH COMPONENTS {...,
payload (WITH COMPONENTS {...,
data ABSENT,
extDataHash PRESENT
}),
headerInfo(WITH COMPONENTS {...,
generationTime PRESENT,
expiryTime ABSENT,
generationLocation ABSENT,
p2pcdLearningRequest ABSENT,
missingCrlIdentifier ABSENT,
encryptionKey ABSENT
})
})
})
})
})
--**********************************************************************
--
-- Structures for describing encrypted data
--
--**********************************************************************
EncryptedData ::= SEQUENCE {
recipients SequenceOfRecipientInfo,
ciphertext SymmetricCiphertext
}
RecipientInfo ::= CHOICE {
pskRecipInfo PreSharedKeyRecipientInfo,
symmRecipInfo SymmRecipientInfo,
certRecipInfo PKRecipientInfo,
signedDataRecipInfo PKRecipientInfo,
rekRecipInfo PKRecipientInfo
}
SequenceOfRecipientInfo ::= SEQUENCE OF RecipientInfo
PreSharedKeyRecipientInfo ::= HashedId8
SymmRecipientInfo ::= SEQUENCE {
recipientId HashedId8,
encKey SymmetricCiphertext
}
PKRecipientInfo ::= SEQUENCE {
recipientId HashedId8,
encKey EncryptedDataEncryptionKey
}
EncryptedDataEncryptionKey ::= CHOICE {
eciesNistP256 EciesP256EncryptedKey,
eciesBrainpoolP256r1 EciesP256EncryptedKey,
...
}
SymmetricCiphertext ::= CHOICE {
aes128ccm AesCcmCiphertext,
...
}
AesCcmCiphertext ::= SEQUENCE {
nonce OCTET STRING (SIZE (12)),
ccmCiphertext Opaque -- 16 bytes longer than plaintext
}
--**********************************************************************
--
-- Certificates and other security management data structures
--
--**********************************************************************
-- Certificates are implicit (type = implicit, toBeSigned includes
-- reconstruction value, signature absent) or explicit (type = explicit,
-- toBeSigned includes verification key, signature present).
Certificate ::= CertificateBase (ImplicitCertificate | ExplicitCertificate)
SequenceOfCertificate ::= SEQUENCE OF Certificate
CertificateBase ::= SEQUENCE {
version Uint8(3),
type CertificateType,
issuer IssuerIdentifier,
toBeSigned ToBeSignedCertificate,
signature Signature OPTIONAL
}
CertificateType ::= ENUMERATED {
explicit,
implicit,
...
}
ImplicitCertificate ::= CertificateBase (WITH COMPONENTS {...,
type(implicit),
toBeSigned (WITH COMPONENTS {...,
verifyKeyIndicator(WITH COMPONENTS {reconstructionValue})
}),
signature ABSENT
})
ExplicitCertificate ::= CertificateBase (WITH COMPONENTS {...,
type(explicit),
toBeSigned(WITH COMPONENTS {...,
verifyKeyIndicator(WITH COMPONENTS {verificationKey})
}),
signature PRESENT
})
IssuerIdentifier ::= CHOICE {
sha256AndDigest HashedId8,
self HashAlgorithm,
...,
sha384AndDigest HashedId8
}
ToBeSignedCertificate ::= SEQUENCE {
id CertificateId,
cracaId HashedId3,
crlSeries CrlSeries,
validityPeriod ValidityPeriod,
region GeographicRegion OPTIONAL,
assuranceLevel SubjectAssurance OPTIONAL,
appPermissions SequenceOfPsidSsp OPTIONAL,
certIssuePermissions SequenceOfPsidGroupPermissions OPTIONAL,
certRequestPermissions SequenceOfPsidGroupPermissions OPTIONAL,
canRequestRollover NULL OPTIONAL,
encryptionKey PublicEncryptionKey OPTIONAL,
verifyKeyIndicator VerificationKeyIndicator,
...
}
(WITH COMPONENTS { ..., appPermissions PRESENT} |
WITH COMPONENTS { ..., certIssuePermissions PRESENT} |
WITH COMPONENTS { ..., certRequestPermissions PRESENT})
CertificateId ::= CHOICE {
linkageData LinkageData,
name Hostname,
binaryId OCTET STRING(SIZE(1..64)),
none NULL,
...
}
LinkageData ::= SEQUENCE {
iCert IValue,
linkage-value LinkageValue,
group-linkage-value GroupLinkageValue OPTIONAL
}
EndEntityType ::= BIT STRING { app (0), enrol (1) } (SIZE (8)) (ALL EXCEPT {})
PsidGroupPermissions ::= SEQUENCE {
subjectPermissions SubjectPermissions,
minChainLength INTEGER DEFAULT 1,
chainLengthRange INTEGER DEFAULT 0,
eeType EndEntityType DEFAULT {app} -- vanetza-idf: IEEE Std 1609.2-2022 6.4.28 (was '00'H)
}
SequenceOfPsidGroupPermissions ::= SEQUENCE OF PsidGroupPermissions
SubjectPermissions ::= CHOICE {
explicit SequenceOfPsidSspRange,
all NULL,
...
}
VerificationKeyIndicator ::= CHOICE {
verificationKey PublicVerificationKey,
reconstructionValue EccP256CurvePoint,
...
}
END
@@ -0,0 +1,330 @@
IEEE1609dot2BaseTypes {
iso(1) identified-organization(3) ieee(111) standards-association-numbered-series-standards(2) wave-stds(1609) dot2(2) base(1) base-types(2) major-version-2(2)
}
--
--********************************************************************
-- IEEE P1609.2 Base Data Types
--
--********************************************************************
DEFINITIONS AUTOMATIC TAGS ::= BEGIN
EXPORTS ALL;
-- ------------------------------------------------------------------
--
-- Integers
--
-- ------------------------------------------------------------------
Uint3 ::= INTEGER (0..7) -- (hex) 07
Uint8 ::= INTEGER (0..255) -- (hex) ff
Uint16 ::= INTEGER (0..65535) -- (hex) ff ff
Uint32 ::= INTEGER (0..4294967295) -- (hex) ff ff ff ff
Uint64 ::= INTEGER (0..18446744073709551615) -- (hex) ff ff ff ff ff ff ff ff
SequenceOfUint8 ::= SEQUENCE OF Uint8
SequenceOfUint16 ::= SEQUENCE OF Uint16
-- ------------------------------------------------------------------
--
-- OCTET STRING types
--
-- ------------------------------------------------------------------
Opaque ::= OCTET STRING
HashedId10 ::= OCTET STRING (SIZE(10))
HashedId8 ::= OCTET STRING (SIZE(8))
HashedId3 ::= OCTET STRING (SIZE(3))
SequenceOfHashedId3 ::= SEQUENCE OF HashedId3
-- ------------------------------------------------------------------
--
-- Time
--
-- ------------------------------------------------------------------
Time32 ::= Uint32
Time64 ::= Uint64
ValidityPeriod ::= SEQUENCE {
start Time32,
duration Duration
}
Duration ::= CHOICE {
microseconds Uint16,
milliseconds Uint16,
seconds Uint16,
minutes Uint16,
hours Uint16,
sixtyHours Uint16,
years Uint16
}
-- ------------------------------------------------------------------
--
-- Location
--
-- ------------------------------------------------------------------
GeographicRegion ::= CHOICE {
circularRegion CircularRegion,
rectangularRegion SequenceOfRectangularRegion,
polygonalRegion PolygonalRegion,
identifiedRegion SequenceOfIdentifiedRegion,
...
}
CircularRegion ::= SEQUENCE {
center TwoDLocation,
radius Uint16
}
RectangularRegion ::= SEQUENCE {
northWest TwoDLocation,
southEast TwoDLocation
}
SequenceOfRectangularRegion ::= SEQUENCE OF RectangularRegion
PolygonalRegion ::= SEQUENCE SIZE(3..MAX) OF TwoDLocation
TwoDLocation ::= SEQUENCE {
latitude Latitude,
longitude Longitude
}
IdentifiedRegion ::= CHOICE {
countryOnly CountryOnly,
countryAndRegions CountryAndRegions,
countryAndSubregions CountryAndSubregions,
...
}
SequenceOfIdentifiedRegion ::= SEQUENCE OF IdentifiedRegion
CountryOnly ::= Uint16
CountryAndRegions ::= SEQUENCE {
countryOnly CountryOnly,
regions SequenceOfUint8
}
CountryAndSubregions ::= SEQUENCE {
country CountryOnly,
regionAndSubregions SequenceOfRegionAndSubregions
}
RegionAndSubregions ::= SEQUENCE {
region Uint8,
subregions SequenceOfUint16
}
SequenceOfRegionAndSubregions ::= SEQUENCE OF RegionAndSubregions
ThreeDLocation ::= SEQUENCE {
latitude Latitude,
longitude Longitude,
elevation Elevation
}
Latitude ::= NinetyDegreeInt
Longitude ::= OneEightyDegreeInt
Elevation ::= ElevInt
NinetyDegreeInt ::= INTEGER {
min (-900000000),
max (900000000),
unknown (900000001)
} (-900000000..900000001)
KnownLatitude ::= NinetyDegreeInt (min..max) -- Minus 90deg to +90deg in microdegree intervals
UnknownLatitude ::= NinetyDegreeInt (unknown)
OneEightyDegreeInt ::= INTEGER {
min (-1799999999),
max (1800000000),
unknown (1800000001)
} (-1799999999..1800000001)
KnownLongitude ::= OneEightyDegreeInt (min..max)
UnknownLongitude ::= OneEightyDegreeInt (unknown)
ElevInt ::= Uint16 -- Range is from -4096 to 61439 in units of one-tenth of a meter
-- ------------------------------------------------------------------
--
-- Crypto
--
-- ------------------------------------------------------------------
Signature ::= CHOICE {
ecdsaNistP256Signature EcdsaP256Signature,
ecdsaBrainpoolP256r1Signature EcdsaP256Signature,
...,
ecdsaBrainpoolP384r1Signature EcdsaP384Signature
}
EcdsaP256Signature ::= SEQUENCE {
rSig EccP256CurvePoint,
sSig OCTET STRING (SIZE (32))
}
EcdsaP384Signature ::= SEQUENCE {
rSig EccP384CurvePoint,
sSig OCTET STRING (SIZE (48))
}
EccP256CurvePoint ::= CHOICE {
x-only OCTET STRING (SIZE (32)),
fill NULL, -- consistency with 1363 / X9.62
compressed-y-0 OCTET STRING (SIZE (32)),
compressed-y-1 OCTET STRING (SIZE (32)),
uncompressedP256 SEQUENCE {
x OCTET STRING (SIZE (32)),
y OCTET STRING (SIZE (32))
}
}
EccP384CurvePoint ::= CHOICE {
x-only OCTET STRING (SIZE (48)),
fill NULL, -- consistency w 1363 / X9.62
compressed-y-0 OCTET STRING (SIZE (48)),
compressed-y-1 OCTET STRING (SIZE (48)),
uncompressedP384 SEQUENCE {
x OCTET STRING (SIZE (48)),
y OCTET STRING (SIZE (48))
}
}
SymmAlgorithm ::= ENUMERATED {
aes128Ccm,
...
}
HashAlgorithm ::= ENUMERATED {
sha256,
...,
sha384
}
EciesP256EncryptedKey ::= SEQUENCE {
v EccP256CurvePoint,
c OCTET STRING (SIZE (16)),
t OCTET STRING (SIZE (16))
}
EncryptionKey ::= CHOICE {
public PublicEncryptionKey,
symmetric SymmetricEncryptionKey
}
PublicEncryptionKey ::= SEQUENCE {
supportedSymmAlg SymmAlgorithm,
publicKey BasePublicEncryptionKey
}
BasePublicEncryptionKey ::= CHOICE {
eciesNistP256 EccP256CurvePoint,
eciesBrainpoolP256r1 EccP256CurvePoint,
...
}
PublicVerificationKey ::= CHOICE {
ecdsaNistP256 EccP256CurvePoint,
ecdsaBrainpoolP256r1 EccP256CurvePoint,
...,
ecdsaBrainpoolP384r1 EccP384CurvePoint
}
SymmetricEncryptionKey ::= CHOICE {
aes128Ccm OCTET STRING(SIZE(16)),
...
}
-- ------------------------------------------------------------------
--
-- PSID / ITS-AID
--
-- ------------------------------------------------------------------
PsidSsp ::= SEQUENCE {
psid Psid,
ssp ServiceSpecificPermissions OPTIONAL
}
SequenceOfPsidSsp ::= SEQUENCE OF PsidSsp
Psid ::= INTEGER (0..MAX)
SequenceOfPsid ::= SEQUENCE OF Psid
ServiceSpecificPermissions ::= CHOICE {
opaque OCTET STRING (SIZE(0..MAX)),
...,
bitmapSsp BitmapSsp
}
BitmapSsp ::= OCTET STRING (SIZE(0..31))
PsidSspRange ::= SEQUENCE {
psid Psid,
sspRange SspRange OPTIONAL
}
SequenceOfPsidSspRange ::= SEQUENCE OF PsidSspRange
SspRange ::= CHOICE {
opaque SequenceOfOctetString,
all NULL,
... ,
bitmapSspRange BitmapSspRange
}
BitmapSspRange ::= SEQUENCE {
sspValue OCTET STRING (SIZE(1..32)),
sspBitmask OCTET STRING (SIZE(1..32))
}
SequenceOfOctetString ::= SEQUENCE (SIZE (0..MAX)) OF OCTET STRING (SIZE(0..MAX))
-- ------------------------------------------------------------------
--
-- Goes in certs
--
-- ------------------------------------------------------------------
SubjectAssurance ::= OCTET STRING (SIZE(1))
CrlSeries ::= Uint16
-- ------------------------------------------------------------------
--
-- Pseudonym Linkage
--
-- ------------------------------------------------------------------
IValue ::= Uint16
Hostname ::= UTF8String (SIZE(0..255))
LinkageValue ::= OCTET STRING (SIZE(9))
GroupLinkageValue ::= SEQUENCE {
jValue OCTET STRING (SIZE(4)),
value OCTET STRING (SIZE(9))
}
LaId ::= OCTET STRING (SIZE(2))
LinkageSeed ::= OCTET STRING (SIZE(16))
END
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,229 @@
-- Draft V0.0.4_2.2.1 - VAM TS 103 300-1 ASN.1 module
-- Based on the official version available at @url https://forge.etsi.org/rep/ITS/asn1/vam-ts103300_3/-/tree/v2.1.1
-- Modified to import from the CDD module V2.1.1
VAM-PDU-Descriptions {itu-t(0) identified-organization(4) etsi(0) itsDomain(5)
wg1(1) 103300 vam(1) major-version-3(3) minor-version-1(1)}
DEFINITIONS AUTOMATIC TAGS ::=
BEGIN
IMPORTS
AccelerationChangeIndication, BasicContainer, CartesianAngle, ClusterBreakupInfo,
ClusterJoinInfo, ClusterLeaveInfo, Curvature, CurvatureCalculationMode,
DeltaTimeQuarterSecond, GenerationDeltaTime, HeadingChangeIndication, ItsPduHeader, LanePosition,
LateralAcceleration, LongitudinalAcceleration, PathHistory, PathPredicted,
SequenceOfSafeDistanceIndication, SequenceOfTrajectoryInterceptionIndication,
SequenceOfTrajectoryInterceptionIndication, Speed, StabilityChangeIndication, StationId,
TrajectoryInterceptionIndication, VerticalAcceleration, VruClusterInformation, VruDeviceUsage,
VruEnvironment, VruExteriorLights, GeneralizedLanePosition, VruProfileAndSubprofile, VruMovementControl,
VruSizeClass, YawRate, Wgs84Angle
FROM ETSI-ITS-CDD {itu-t (0) identified-organization (4) etsi (0) itsDomain (5) wg1 (1) 102894 cdd (2) major-version-3 (4) minor-version-3 (3) }
WITH SUCCESSORS
;
VAM ::= SEQUENCE {
header ItsPduHeaderVam,
vam VruAwareness
}
/**
* @details ItsPduHeaderVam
* The ITS PDU header for the VAM.
*
* This DF includes DEs for the VAM _protocolVersion_, the VAM message type identifier _messageID_
* and the station identifier _stationID_ of the originating ITS-S.
*
* @category: Communication information
* @revision: V2.2.1
*/
ItsPduHeaderVam ::= ItsPduHeader(WITH COMPONENTS {..., protocolVersion(3), messageId(vam)})
/**
* @details VruAwareness
* VAM payload.
*
* It includes the time stamp of the VAM and the VAM different containers
*
* @category: Communication information
* @revision: V2.2.1
*/
VruAwareness ::= SEQUENCE {
generationDeltaTime GenerationDeltaTime,
vamParameters VamParameters
}
/**
* @details VamParameters
* The VAM payload includes the @ref BasicContainer and @ref VruHighFrequencyContainer.
* The VAM payload may also include additional containers: @ref VruLowFrequencyContainer,
* @ref VruClusterInformationContainer, @ref VruClusterOperationContainer and @ref VruMotionPredictionContainer.
* The selection of the additional containers depends on the dissemination criteria,
* e.g. _vruCluster_ or _MotionDynamicPrediction_ availability.
*
* @category: Communication information
* @revision: V2.2.1
**/
VamParameters ::= SEQUENCE {
basicContainer BasicContainer,
vruHighFrequencyContainer VruHighFrequencyContainer,
vruLowFrequencyContainer VruLowFrequencyContainer OPTIONAL,
vruClusterInformationContainer VruClusterInformationContainer OPTIONAL,
vruClusterOperationContainer VruClusterOperationContainer OPTIONAL,
vruMotionPredictionContainer VruMotionPredictionContainer OPTIONAL,
...
}
/**
* @details VruHighFrequencyContainer
* The VRU HF container of the VAM contains potentially fast-changing status information of the VRU ITS-S.
* It includes the following components (setting indications are specified in clause 7.3.3 of TS 103 300-3):
*
* @field heading: heading and heading confidence of the originating VRU with regards to the true north.
* @field speed: speed in moving direction and speed confidence of the originating VRU.
* @field longitudinalAcceleration: longitudinal acceleration of the originating VRU.
* @field curvature: related to the actual trajectory of the originating VRU vehicle.
_(recommended for VRU Profile 2)_
* @field curvatureCalculationMode: indicates whether vehicle yaw-rate is used in the calculation of
* the curvature of the VRU vehicle ITS-S that originates the VAM. _(recommended for VRU Profile 2)_
* @field yawRate: yaw rate of originating VRU vehicle. _(recommended for VRU Profile 2)_
* @field lateralAcceleration: originating VRU lateral acceleration in the street plane.
* This field shall be present if the data is available at the originating ITS-S. _(recommended for VRU Profile 2)_
* @field verticalAcceleration: vertical acceleration of the originating VRU.
* This field shall be present if the data is available at the originating ITS-S.
* @field vruLanePosition: lane position of the referencePosition of a VRU, which is either a VRU-specific non-traffic lane
* or a standard traffic lane. This field shall be present if the data is available at the originating ITS-S.
* @field environment: provides contextual awareness of the VRU among other road users.
* This field shall be present only if the data is available at the originating ITS-S.
* @field movementControl: indicates the mechanism used by the VRU to control the longitudinal movement of the VRU vehicle.
* This field shall be present only if the data is available at the originating ITS-S. _(recommended for VRU Profile 2)_
* @field orientation : complements the dimensions of the VRU vehicle by defining the angle of the VRU vehicle longitudinal
* axis with regards to the WGS84 north. _(recommended for VRU Profile 2)_
* @field rollAngle: provides the angle and angle accuracy between the ground plane and the current orientation of a vehicle's
* y-axis with respect to the ground plane about the x-axis according to the ISO 8855.
* This field shall be present only if the data is available at the originating ITS-S. _(recommended for VRU Profile 2)_
* @field deviceUsage: provides indications from the personal device about the potential
* activity of the VRU. This field shall be present only if the data is available at the originating ITS-S.
* _(recommended for VRU Profile 1)_
*
* @category: VRU information
* @revision: V2.2.1
*/
VruHighFrequencyContainer ::= SEQUENCE {
heading Wgs84Angle,
speed Speed,
longitudinalAcceleration LongitudinalAcceleration,
curvature Curvature OPTIONAL,
curvatureCalculationMode CurvatureCalculationMode OPTIONAL,
yawRate YawRate OPTIONAL,
lateralAcceleration LateralAcceleration OPTIONAL,
verticalAcceleration VerticalAcceleration OPTIONAL,
vruLanePosition GeneralizedLanePosition OPTIONAL,
environment VruEnvironment OPTIONAL,
movementControl VruMovementControl OPTIONAL,
orientation Wgs84Angle OPTIONAL,
rollAngle CartesianAngle OPTIONAL,
deviceUsage VruDeviceUsage OPTIONAL,
...
}
/**
* @details VruLowFrequencyContainer
* The VRU LF container of the VAM contains potentially slow-changing information of the VRU ITS-S.
* It is mandatory with higher periodicity as specified in clause 6.2 or when VRU cluster operation container is present.
* It includes the following components (setting indications are specified in clause 7.3.4 of TS 103 300-3):
*
* @field profileAndSubprofile: profile of the ITS-S that originates the VAM, including sub-profile information.
* @field sizeClass: information about the size of the VRU.
* @field exteriorLights: status of the most important exterior lights switches of the VRU ITS-S that originates the VAM.
* _(conditional mandatory as specified in clause 7.3.4 of TS 103 300-3)_
*
* @category: VRU information
* @revision: V2.2.1
*/
VruLowFrequencyContainer ::= SEQUENCE {
profileAndSubprofile VruProfileAndSubprofile,
sizeClass VruSizeClass OPTIONAL,
exteriorLights VruExteriorLights OPTIONAL,
...
}
/**
* @details VruClusterInformationContainer
* The VRU Cluster Information container of the VAM provides the information/parameters relevant to the VRU cluster.
* It is mandatory if the VAM is transmitted by VRU cluster leader.
* It includes the following components (setting indications are specified in clause 7.3.5 of TS 103 300-3):
*
* @field vruClusterInformation: set of parammeters releated to the VRU cluster.
* When transmitted by a VRU ITS-S, the clusterId and clusterBoundingBoxShape fields inside this DF shall be present.
* The clusterBoundingBoxShape is positioned with respect to the position sent in the BasicContainer.
*
* @category: VRU information
* @revision: V2.2.1
*/
VruClusterInformationContainer::= SEQUENCE{
vruClusterInformation VruClusterInformation (WITH COMPONENTS{..., clusterId, clusterBoundingBoxShape PRESENT}),
...
}
/**
* @details VruClusterOperationContainer
* The VRU Cluster Operation container of the VAM provides information relevant to change of cluster state and composition.
* It is mandatory if the VAM is transmitted by a VRU joining, leaving or breaking up a cluster.
* It includes the following components (setting indications are specified in clause 7.3.5 of TS 103 300-3).
* At least one of the fields below shall be present if the container is present in the VAM:
*
* @field clusterJoinInfo: indicates the intent of an individual VAM transmitter to join a cluster.
* @field clusterLeaveInfo : indicates that an individual VAM transmitter has recently left the VRU cluster.
* @field clusterBreakupInfo: indicates the intent of a cluster VAM transmitter to stop sending cluster VAMs.
* @field clusterIdChangeTimeInfo: indicates the intent of a cluster VAM transmitter to change cluster ID.
*
* @category: VRU information
* @revision: V2.2.1
*/
VruClusterOperationContainer ::= SEQUENCE {
clusterJoinInfo ClusterJoinInfo OPTIONAL,
clusterLeaveInfo ClusterLeaveInfo OPTIONAL,
clusterBreakupInfo ClusterBreakupInfo OPTIONAL,
clusterIdChangeTimeInfo DeltaTimeQuarterSecond OPTIONAL,
...
}
/**
* @details VruMotionPredictionContainer
* The VRU Motion Prediction container of the VAM carries the past and future motion state information of the VRU.
* It includes the following components (setting indications are specified in clause 7.3.6 of TS 103 300-3).
* At least one of the fields below shall be present if the container is present in the VAM:
*
* @field pathHistory: represents the VRU's recent movement over some past time and/or distance.
* It consists of a list of path points.
* @field pathPrediction: provides the set of predicted locations of the ITS-S, confidence values
* and the corresponding future time instants.
* @field safeDistance: provides indication of safe distance between an ego-VRU and up to 8 other ITS-S
* or entity on the road to indicate whether the ego-VRU is at a safe distance (that is less likely to
* physically collide) from another ITS-S or entity on the road.
* @field trajectoryInterceptionIndication: provides the indication for possible trajectory interception
* with up to 8 VRUs or other objects on the road..
* @field accelerationChangeIndication: provides an acceleration change indication of the VRU.
* When present this DF indicates an anticipated change in the VRU speed for period of actionDeltaTime.
* @field headingChangeIndication: provides additional data elements associated to heading change indicators
* such as a change of travel direction (left or right).
* The direction change action is performed for a period of actionDeltaTime.
* @field stabilityChangeIndication: provides an estimation of the VRU stability.
*
* @category: GeoReference information, VRU information
* @revision: V2.2.1
*/
VruMotionPredictionContainer ::= SEQUENCE {
pathHistory PathHistory OPTIONAL,
pathPrediction PathPredicted OPTIONAL,
safeDistance SequenceOfSafeDistanceIndication OPTIONAL,
trajectoryInterceptionIndication SequenceOfTrajectoryInterceptionIndication OPTIONAL,
accelerationChangeIndication AccelerationChangeIndication OPTIONAL,
headingChangeIndication HeadingChangeIndication OPTIONAL,
stabilityChangeIndication StabilityChangeIndication OPTIONAL,
...
}
END
@@ -0,0 +1,232 @@
"""
Builds v2x2map-bridge_<version>_all.deb for Debian/Ubuntu.
A .deb is an ar(1) archive containing three members:
debian-binary plain text "2.0\\n"
control.tar.gz DEBIAN/ metadata
data.tar.gz the files to install
No external tools required — pure Python (tarfile + gzip).
Run from V2X2MAP/bridge:
python build_deb.py
Output: dist/v2x2map-bridge_0.2.1_all.deb
"""
import gzip
import hashlib
import io
import os
import stat
import tarfile
import time
VERSION = "0.2.1"
PACKAGE = "v2x2map-bridge"
ARCH = "all"
# ── ar archive ───────────────────────────────────────────────────────────────
def _ar_header(name: str, size: int) -> bytes:
ts = str(int(time.time()))
return (
name.encode().ljust(16)[:16]
+ ts.encode().ljust(12)[:12]
+ b"0 " # uid
+ b"0 " # gid
+ b"100644 " # mode
+ str(size).encode().ljust(10)[:10]
+ b"`\n"
)
def ar_pack(members: list[tuple[str, bytes]]) -> bytes:
buf = bytearray(b"!<arch>\n")
for name, data in members:
buf += _ar_header(name, len(data))
buf += data
if len(data) % 2:
buf += b"\n" # ar entries must start on even offsets
return bytes(buf)
# ── tar.gz builder ────────────────────────────────────────────────────────────
def make_targz(entries: list) -> bytes:
"""
entries is a list of:
("./path/to/dir/", None, 0o755) → directory
("./path/to/file", bytes_content, 0o644) → regular file
"""
raw = io.BytesIO()
with gzip.GzipFile(fileobj=raw, mode="wb", mtime=0) as gz:
tf = tarfile.open(fileobj=gz, mode="w:")
for path, content, mode in entries:
info = tarfile.TarInfo(name=path)
info.mtime = 0
info.uid = 0
info.gid = 0
info.uname = "root"
info.gname = "root"
if content is None:
info.type = tarfile.DIRTYPE
info.mode = mode
tf.addfile(info)
else:
info.mode = mode
info.size = len(content)
tf.addfile(info, io.BytesIO(content))
tf.close()
return raw.getvalue()
# ── file content helpers ───────────────────────────────────────────────────────
def _read(rel: str) -> bytes:
with open(rel, "rb") as f:
return f.read()
def _txt(s: str) -> bytes:
return s.encode("utf-8")
# ── package metadata ──────────────────────────────────────────────────────────
WRAPPER = _txt("""\
#!/bin/bash
exec "${V2X2MAP_PYTHON:-python3}" /usr/lib/v2x2map/its_g5_bridge.py "$@"
""")
CONTROL = _txt(f"""\
Package: {PACKAGE}
Version: {VERSION}
Architecture: {ARCH}
Maintainer: pit711
Installed-Size: 250
Depends: python3 (>= 3.10), python3-serial, python3-paho-mqtt
Description: V2X2MAP ITS-G5 USB-to-MQTT bridge and live dashboard
Captures IEEE 802.11p / ITS-G5 frames from an ESP32-C5 via USB serial
and republishes them to one or more MQTT brokers.
.
Includes a live web dashboard (http://127.0.0.1:8080) with:
- Map view with type-specific vehicle/RSU markers
- Grouped frame log with filter chips
- Statistics tab (type breakdown, sparkline, broker status)
- MAC forensics tab (first/last seen, frame count, CSV export)
- Runtime PCAP recording
.
Config : ~/.config/v2x2map/v2x2map.cfg (JSON, created on first run)
Records : ~/v2x2map/recordings/its5_*.pcap
.
Quick start:
v2x2map-bridge --node-id V2X2MAP:d2cf13ed6293
.
Subsequent runs (reads config automatically):
v2x2map-bridge
""")
POSTINST = _txt("""\
#!/bin/bash
set -e
echo ""
echo " V2X2MAP ITS-G5 bridge installed."
echo " For VAM decoding install requirements.txt in a venv and set V2X2MAP_PYTHON to its python."
echo " Usage: v2x2map-bridge --node-id V2X2MAP:<12-hex-mac>"
echo " Dashboard opens at http://127.0.0.1:8080"
echo ""
echo " Serial port access: add your user to the 'dialout' group:"
echo " sudo usermod -aG dialout $USER (re-login required)"
echo ""
""")
PRERM = _txt("""\
#!/bin/bash
set -e
""")
COPYRIGHT = _txt(f"""\
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: {PACKAGE}
Source: https://github.com/pit711/v2x2map
Files: *
Copyright: 2026 pit711
License: MIT
""")
# ── md5sums ───────────────────────────────────────────────────────────────────
def md5sums(file_entries: list[tuple[str, bytes]]) -> bytes:
lines = []
for path, content, _ in file_entries:
if content is not None:
h = hashlib.md5(content).hexdigest()
lines.append(f"{h} {path.lstrip('./')}")
return _txt("\n".join(lines) + "\n")
# ── build ─────────────────────────────────────────────────────────────────────
def build():
os.makedirs("dist", exist_ok=True)
bridge_py = _read("its_g5_bridge.py")
server_py = _read("dashboard_server.py")
dash_html = _read("dashboard.html")
# Files that will be installed (used for md5sums too)
data_files = [
("./usr/lib/v2x2map/its_g5_bridge.py", bridge_py, 0o644),
("./usr/lib/v2x2map/dashboard_server.py", server_py, 0o644),
("./usr/lib/v2x2map/dashboard.html", dash_html, 0o644),
("./usr/bin/v2x2map-bridge", WRAPPER, 0o755),
("./usr/share/doc/v2x2map-bridge/copyright", COPYRIGHT, 0o644),
]
for name in ("vam.py", "requirements.txt", "asn1/VAM-PDU-Descriptions.asn",
"asn1/TS102894-2v241-CDD.asn", "asn1/COPYRIGHT.md"):
data_files.append(("./usr/lib/v2x2map/" + name, _read(name), 0o644))
data_entries = [
("./", None, 0o755),
("./usr/", None, 0o755),
("./usr/lib/", None, 0o755),
("./usr/lib/v2x2map/", None, 0o755),
("./usr/bin/", None, 0o755),
("./usr/share/", None, 0o755),
("./usr/share/doc/", None, 0o755),
("./usr/share/doc/v2x2map-bridge/", None, 0o755),
] + data_files
ctrl_entries = [
("./", None, 0o755),
("./control", CONTROL, 0o644),
("./md5sums", md5sums(data_files), 0o644),
("./postinst", POSTINST, 0o755),
("./prerm", PRERM, 0o755),
]
control_tgz = make_targz(ctrl_entries)
data_tgz = make_targz(data_entries)
deb = ar_pack([
("debian-binary", b"2.0\n"),
("control.tar.gz", control_tgz),
("data.tar.gz", data_tgz),
])
name = f"{PACKAGE}_{VERSION}_{ARCH}.deb"
out = os.path.join("dist", name)
with open(out, "wb") as f:
f.write(deb)
kb = len(deb) // 1024
print(f"Built: {out} ({kb} KB)")
print(f"Install with:")
print(f" sudo apt install ./{name}")
print(f" -- or --")
print(f" sudo dpkg -i {name}")
if __name__ == "__main__":
build()
@@ -0,0 +1,55 @@
# Builds two Windows EXEs for V2X2MAP:
# dist\its-g5-bridge.exe -- bridge only, starts directly (reads config\v2x2map.cfg)
# dist\its-g5-setup.exe -- setup wizard + firmware flash
#
# Run from V2X2MAP\bridge:
# .\build_exe.ps1
$ErrorActionPreference = 'Continue'
# Refresh bundled firmware bins from the latest build/ if it exists.
$buildDir = Resolve-Path "..\firmware\build" -ErrorAction SilentlyContinue
if ($buildDir) {
Copy-Item "$buildDir\bootloader\bootloader.bin" ".\firmware\bootloader.bin" -Force
Copy-Item "$buildDir\partition_table\partition-table.bin" ".\firmware\partition-table.bin" -Force
Copy-Item "$buildDir\ota_data_initial.bin" ".\firmware\ota_data_initial.bin" -Force
Copy-Item "$buildDir\its-g5-receiver-firmware.bin" ".\firmware\firmware.bin" -Force
Write-Host "Firmware-Bins aktualisiert aus $buildDir"
} else {
Write-Host "Kein ..\firmware\build\ gefunden -- bestehende firmware\*.bin werden verwendet."
}
# Ensure required packages are available.
python -m pip install --quiet -r requirements.txt pyinstaller esptool pillow
# Generate icons
Write-Host ""
Write-Host "=== Generiere Icons ==="
python make_icons.py
if ($LASTEXITCODE -ne 0) { Write-Warning "Icon-Generierung fehlgeschlagen -- baue ohne Icons weiter." }
Write-Host ""
Write-Host "=== Baue its-g5-bridge.exe (Bridge ohne Wizard) ==="
python -m PyInstaller --noconfirm its-g5-bridge.spec
if ($LASTEXITCODE -ne 0) { Write-Error "Bridge-Build fehlgeschlagen."; exit 1 }
Write-Host ""
Write-Host "=== Baue its-g5-setup.exe (Setup-Wizard und Flash) ==="
python -m PyInstaller --noconfirm its-g5-setup.spec
if ($LASTEXITCODE -ne 0) { Write-Error "Setup-Build fehlgeschlagen."; exit 1 }
$bridge = (Get-Item "dist\its-g5-bridge.exe").Length / 1MB
$setup = (Get-Item "dist\its-g5-setup.exe").Length / 1MB
Write-Host ""
Write-Host "Fertig:"
Write-Host (" dist\its-g5-bridge.exe {0:N1} MB -- Doppelklick zum Starten" -f $bridge)
Write-Host (" dist\its-g5-setup.exe {0:N1} MB -- Einrichten und Flashen" -f $setup)
Write-Host ""
Write-Host "Ordnerstruktur nach dem ersten Start:"
Write-Host " dist\"
Write-Host " its-g5-bridge.exe"
Write-Host " its-g5-setup.exe"
Write-Host " config\"
Write-Host " v2x2map.cfg (von Setup geschrieben)"
Write-Host " recordings\"
Write-Host " its5_*.pcap (PCAP-Aufnahmen)"
@@ -0,0 +1,790 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>V2X2MAP — ITS-G5 Dashboard</title>
<meta name="description" content="V2X2MAP ITS-G5 live dashboard">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
<style>
* { box-sizing: border-box; }
html, body { margin:0; font-family:ui-monospace,Consolas,monospace; font-size:14px;
background:#0e1116; color:#dde; height:100%; }
/* ── Header ──────────────────────────────────────────────────────────────── */
header {
padding:7px 14px; background:#161b22; border-bottom:1px solid #30363d;
display:flex; gap:14px; align-items:center; flex-wrap:wrap; flex-shrink:0;
}
header h1 { font-size:15px; margin:0; color:#fff; font-weight:700; letter-spacing:.5px; }
.stat { font-size:12px; color:#8b949e; white-space:nowrap; }
.stat b { color:#58a6ff; }
#serial-dot { display:inline-block; width:9px; height:9px; border-radius:50%;
background:#444; margin-right:4px; vertical-align:middle; }
#serial-dot.up { background:#3fb950; }
#serial-dot.down { background:#da3633; }
#conn { font-size:12px; padding:3px 9px; border-radius:10px; }
#conn.up { background:#1f6feb; color:#fff; }
#conn.down { background:#da3633; color:#fff; }
.btn {
font-family:inherit; font-size:12px; cursor:pointer;
padding:3px 11px; border-radius:12px; border:1px solid #30363d;
background:#21262d; color:#8b949e;
}
.btn:hover:not([disabled]) { background:#30363d; color:#dde; }
.btn.on { background:#238636; color:#fff; border-color:#238636; }
.btn.on:hover { background:#2ea043; }
.btn.rec { background:#6e2020; color:#f47979; border-color:#6e2020; }
.btn.rec:hover { background:#8a2828; }
.btn[disabled] { opacity:.5; cursor:not-allowed; }
/* ── Layout ─────────────────────────────────────────────────────────────── */
main { display:grid; grid-template-columns:1fr 1fr; gap:1px;
background:#30363d; height:calc(100vh - 44px); min-height:0; }
#map { min-height:0; }
/* locate button */
.locate-btn { width:30px; height:30px; font-size:19px; line-height:28px; text-align:center;
background:#fff; border:none; cursor:pointer; display:block;
color:#333; border-radius:2px; }
.locate-btn:hover { background:#f4f4f4; }
/* ── Right panel ─────────────────────────────────────────────────────────── */
#rightpanel { background:#0d1117; display:flex; flex-direction:column; min-height:0; overflow:hidden; }
/* Tab bar */
#tab-bar { display:flex; border-bottom:1px solid #21262d; flex-shrink:0; }
.tab {
font-family:inherit; font-size:12px; padding:7px 14px; cursor:pointer;
border:none; background:none; color:#8b949e; border-bottom:2px solid transparent;
}
.tab:hover { color:#dde; background:#161b22; }
.tab.active { color:#58a6ff; border-bottom-color:#58a6ff; }
/* Tab panes */
.pane { display:none; flex:1; min-height:0; flex-direction:column; overflow:hidden; }
.pane.active { display:flex; }
/* ── Log pane ────────────────────────────────────────────────────────────── */
#log-filters {
display:flex; align-items:center; gap:6px; flex-wrap:wrap;
padding:6px 10px; border-bottom:1px solid #161b22; flex-shrink:0;
}
.fchip {
font-family:inherit; font-size:12px; padding:2px 9px; border-radius:10px;
border:1px solid #30363d; background:#21262d; color:#8b949e; cursor:pointer;
}
.fchip:hover { background:#30363d; }
.fchip.on { color:#fff; border-color:transparent; }
.fchip[data-type="VAM"].on { background:#009688; }
.fchip[data-type="CAM"].on { background:#1565c0; }
.fchip[data-type="DENM"].on { background:#bf360c; }
.fchip[data-type="SPATEM"].on { background:#2e7d32; }
.fchip[data-type="MAPEM"].on { background:#6a1b9a; }
.fchip[data-type="OTHER"].on { background:#546e7a; }
#station-list { flex:1; overflow-y:auto; }
#empty-log { padding:20px; color:#555; text-align:center; }
.srow {
display:grid;
grid-template-columns:32px 58px 96px 1fr 70px 36px 20px 20px;
align-items:center; padding:5px 8px; border-bottom:1px solid #161b22;
cursor:pointer; user-select:none; gap:0 4px;
}
.srow:hover { background:#161b22; }
.srow.stale { opacity:.4; }
.srow-icon { display:flex; align-items:center; justify-content:center; }
.srow-badge { font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; }
.srow-id { font-size:11px; color:#79c0ff; }
.srow-sum { font-size:12px; color:#8b949e; overflow:hidden; text-overflow:ellipsis; }
.srow-time { font-size:11px; color:#555; text-align:right; }
.srow-count { font-size:11px; color:#555; text-align:right; }
.srow-lock { font-size:12px; text-align:center; }
.srow-chev { font-size:12px; color:#555; text-align:center; }
.frow {
display:grid; grid-template-columns:70px 60px 38px 1fr;
padding:3px 8px 3px 42px; background:#080c10;
border-bottom:1px solid #0d1117; gap:0 6px; cursor:default;
}
.frow-time { font-size:11px; color:#555; }
.frow-et { font-size:11px; font-weight:600; }
.frow-len { font-size:11px; color:#555; }
.frow-hex { font-size:11px; color:#79c0ff; overflow:hidden; text-overflow:ellipsis; }
.et-its { color:#7ee787; }
.et-wlan { color:#8b949e; }
.et-unk { color:#f0883e; }
/* ── Stats pane ──────────────────────────────────────────────────────────── */
#pane-stats { overflow-y:auto; padding:14px; gap:16px; }
.stats-card { background:#161b22; border:1px solid #30363d; border-radius:8px; padding:12px 16px; }
.stats-card h3 { margin:0 0 10px; font-size:13px; color:#8b949e; text-transform:uppercase; letter-spacing:.8px; }
.kv-grid { display:grid; grid-template-columns:1fr 1fr; gap:8px 24px; }
.kv { display:flex; flex-direction:column; }
.kv-key { font-size:11px; color:#555; }
.kv-value { font-size:15px; color:#58a6ff; font-weight:600; }
#type-table { width:100%; border-collapse:collapse; font-size:13px; }
#type-table th { color:#8b949e; text-align:left; padding:4px 8px; font-weight:500;
border-bottom:1px solid #30363d; }
#type-table td { padding:5px 8px; border-bottom:1px solid #161b22; }
#type-table td:last-child { text-align:right; color:#555; }
.broker-row { display:flex; align-items:center; gap:8px; padding:5px 0;
border-bottom:1px solid #161b22; font-size:12px; }
.broker-dot { width:9px; height:9px; border-radius:50%; background:#555; flex-shrink:0; }
.broker-dot.up { background:#3fb950; }
/* rate sparkline */
#sparkline { display:flex; align-items:flex-end; gap:1px; height:48px;
margin-top:8px; }
.spark-bar { flex:1; background:#1f6feb; border-radius:1px 1px 0 0; min-height:2px; }
/* ── MAC pane ────────────────────────────────────────────────────────────── */
#pane-macs { overflow:hidden; }
#mac-toolbar { padding:8px 10px; border-bottom:1px solid #161b22; flex-shrink:0; display:flex; gap:8px; }
#mac-search {
font-family:inherit; font-size:13px; background:#161b22; border:1px solid #30363d;
color:#dde; padding:4px 10px; border-radius:6px; flex:1;
}
#mac-search::placeholder { color:#555; }
#mac-table-wrap { flex:1; overflow-y:auto; }
#mac-table { width:100%; border-collapse:collapse; font-size:12px; }
#mac-table th {
position:sticky; top:0; background:#161b22; color:#8b949e; padding:5px 8px;
text-align:left; font-weight:500; border-bottom:1px solid #30363d;
cursor:pointer; user-select:none; white-space:nowrap;
}
#mac-table th:hover { color:#dde; }
#mac-table td { padding:4px 8px; border-bottom:1px solid #161b22; white-space:nowrap; }
#mac-table tr:hover td { background:#161b22; }
.mac-addr { color:#79c0ff; font-family:inherit; }
.mac-types { color:#8b949e; font-size:11px; }
#mac-empty { padding:20px; color:#555; text-align:center; }
/* Leaflet popup */
.leaflet-popup-content { font-family:ui-monospace,Consolas,monospace; font-size:13px; line-height:1.6; }
.leaflet-popup-content .lbl { color:#666; display:inline-block; min-width:72px; }
.mi { background:none; border:none; }
.mi svg { display:block; filter:drop-shadow(0 0 2px rgba(0,0,0,.7)); }
</style>
</head>
<body>
<header>
<h1>V2X2MAP</h1>
<span class="stat">node <b id="node">?</b></span>
<span class="stat">total <b id="ntotal">0</b></span>
<span class="stat">60s <b id="nrate">0</b></span>
<span class="stat">B/s <b id="nbytes">—</b></span>
<span class="stat">last <b id="last">–</b></span>
<span class="stat"><span id="serial-dot"></span><span id="serial-lbl">—</span></span>
<span style="flex:1"></span>
<button id="rec-btn" class="btn" type="button" title="Start/stop PCAP recording">⏺ Record</button>
<button id="mqtt-toggle" class="btn" type="button">MQTT: <span id="mqtt-state-text">off</span></button>
<span id="conn" class="down">disconnected</span>
</header>
<main>
<div id="map"></div>
<div id="rightpanel">
<div id="tab-bar">
<button class="tab active" data-tab="log">Log</button>
<button class="tab" data-tab="stats">Statistics</button>
<button class="tab" data-tab="macs">MAC Analysis</button>
</div>
<!-- ── Log pane ─────────────────────────────────────────────────────── -->
<div id="pane-log" class="pane active">
<div id="log-filters">
<button class="fchip on" data-type="VAM">VAM</button>
<button class="fchip on" data-type="CAM">CAM</button>
<button class="fchip on" data-type="DENM">DENM</button>
<button class="fchip on" data-type="SPATEM">SPATEM</button>
<button class="fchip on" data-type="MAPEM">MAPEM</button>
<button class="fchip on" data-type="OTHER">Other</button>
</div>
<div id="station-list"><div id="empty-log">Waiting for frames...</div></div>
</div>
<!-- ── Stats pane ────────────────────────────────────────────────────── -->
<div id="pane-stats" class="pane">
<div class="stats-card">
<h3>Session</h3>
<div class="kv-grid">
<div class="kv"><span class="kv-key">Uptime</span><span class="kv-value" id="st-uptime">—</span></div>
<div class="kv"><span class="kv-key">Total frames</span><span class="kv-value" id="st-frames">0</span></div>
<div class="kv"><span class="kv-key">Rate (60 s)</span><span class="kv-value" id="st-rate">0 /min</span></div>
<div class="kv"><span class="kv-key">Frequency</span><span class="kv-value" id="st-freq">-</span></div>
<div class="kv"><span class="kv-key">Interval &plusmn; Jitter</span><span class="kv-value" id="st-interval">-</span></div>
<div class="kv"><span class="kv-key">Interval Range</span><span class="kv-value" id="st-range">-</span></div>
<div class="kv"><span class="kv-key">Total bytes</span><span class="kv-value" id="st-bytes">0</span></div>
</div>
<div style="margin-top:10px;font-size:11px;color:#555">Frames per second (last 60 s)</div>
<div id="sparkline"></div>
</div>
<div class="stats-card">
<h3>Message types</h3>
<table id="type-table">
<thead><tr><th>Type</th><th>Description</th><th>Frames</th></tr></thead>
<tbody id="type-tbody"></tbody>
</table>
</div>
<div class="stats-card">
<h3>MQTT Brokers</h3>
<div id="broker-list"><span style="color:#555;font-size:12px">No broker configured.</span></div>
</div>
<div class="stats-card">
<h3>Recording</h3>
<div id="rec-status" style="font-size:13px;color:#555">No active recording.</div>
<div style="margin-top:8px;display:flex;gap:8px;">
<button class="btn" id="rec-start-btn" onclick="startRecording()">Start</button>
<button class="btn" id="rec-stop-btn" onclick="stopRecording()" disabled>Stop</button>`r`n <a class="btn" id="rec-dl-btn" href="/api/record/download" download style="display:none;text-decoration:none;padding:5px 10px;font-size:13px;background:#2e7d32;color:#fff;">📥 Download PCAP</a>
</div>
</div>
</div>
<!-- ── MAC pane ──────────────────────────────────────────────────────── -->
<div id="pane-macs" class="pane">
<div id="mac-toolbar">
<input id="mac-search" type="search" placeholder="Filter MAC address..." oninput="renderMacTable()">
<button class="btn" onclick="exportMacCsv()">Export CSV</button>
</div>
<div id="mac-table-wrap">
<table id="mac-table">
<thead>
<tr>
<th onclick="sortMac('mac')">MAC ↕</th>
<th onclick="sortMac('type')">Type ↕</th>
<th onclick="sortMac('station')">Station ↕</th>
<th onclick="sortMac('first')">First seen ↕</th>
<th onclick="sortMac('last')">Last seen ↕</th>
<th onclick="sortMac('count')">Frames ↕</th>
<th>Position</th>
</tr>
</thead>
<tbody id="mac-tbody"><tr><td colspan="7" id="mac-empty">No MACs received yet.</td></tr></tbody>
</table>
</div>
</div>
</div>
</main>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script>
'use strict';
// ─── Message type config ──────────────────────────────────────────────────
const MSG = {
VAM: { color:'#009688', short:'VAM', label:'Vulnerable road user (VAM)' },
CAM: { color:'#1976D2', short:'CAM', label:'Vehicle (CAM)' },
DENM: { color:'#E65100', short:'DENM', label:'Hazard warning (DENM)' },
MAPEM: { color:'#7B1FA2', short:'MAPEM', label:'Intersection map (MAPEM)' },
SPATEM: { color:'#388E3C', short:'SPATEM', label:'Signal phase (SPATEM)' },
IVIM: { color:'#C2185B', short:'IVIM', label:'In-vehicle info (IVIM)' },
SREM: { color:'#00838F', short:'SREM', label:'Signal request (SREM)' },
SSEM: { color:'#00838F', short:'SSEM', label:'Signal status (SSEM)' },
TLM: { color:'#AFB42B', short:'TLM', label:'Traffic light (TLM)' },
RTCMEM: { color:'#455A64', short:'RTCM', label:'Correction data (RTCMEM)' },
UNKNOWN:{ color:'#607D8B', short:'?', label:'Unknown' },
};
const PRIMARY = ['CAM','DENM','SPATEM','MAPEM','VAM'];
function typeConf(t) { return MSG[t] || MSG.UNKNOWN; }
// ─── SVG icons ────────────────────────────────────────────────────────────
function svg(w,h,vb,body){return`<svg width="${w}" height="${h}" viewBox="${vb}" xmlns="http://www.w3.org/2000/svg">${body}</svg>`;}
function carSvg(c,r){const rot=isFinite(r)?r:0;return svg(28,28,'-14 -14 28 28',`<g style="transform:rotate(${rot}deg);transform-origin:0 0"><path d="M0,-11 L8,8 L0,3 L-8,8 Z" fill="${c}" stroke="#fff" stroke-width="1.5"/></g>`);}
function tlSvg(c){return svg(22,30,'-11 -15 22 30',`<rect x="-7" y="-14" width="14" height="28" rx="4" fill="${c}"/><circle cx="0" cy="-7" r="4" fill="#fff" opacity=".35"/><circle cx="0" cy="0" r="4" fill="#fff" opacity=".35"/><circle cx="0" cy="7" r="4" fill="#fff" opacity=".35"/>`);}
function hazSvg(c){return svg(28,26,'-14 -13 28 26',`<path d="M0,-12 L12.5,10 L-12.5,10 Z" fill="${c}" stroke="#fff" stroke-width="1.5"/><text x="0" y="8" text-anchor="middle" fill="#fff" font-size="11" font-weight="bold" font-family="sans-serif">!</text>`);}
function intSvg(c){return svg(26,26,'-13 -13 26 26',`<line x1="0" y1="-12" x2="0" y2="12" stroke="${c}" stroke-width="4" stroke-linecap="round"/><line x1="-12" y1="0" x2="12" y2="0" stroke="${c}" stroke-width="4" stroke-linecap="round"/><circle cx="0" cy="0" r="4.5" fill="${c}" stroke="#fff" stroke-width="1.5"/>`);}
function dotSvg(c){return svg(20,20,'-10 -10 20 20',`<circle r="8" fill="${c}" stroke="#fff" stroke-width="1.5"/>`);}
function typeSvg(t,c,h){switch(t){case'CAM':return carSvg(c,h);case'SPATEM':return tlSvg(c);case'DENM':return hazSvg(c);case'MAPEM':return intSvg(c);default:return dotSvg(c);}}
function mkIcon(t,c,h){const sizes={CAM:[28,28],SPATEM:[22,30],DENM:[28,26],MAPEM:[26,26]};const[w,sz]=sizes[t]||[20,20];return L.divIcon({className:'mi',html:typeSvg(t,c,h),iconSize:[w,sz||w],iconAnchor:[(w)/2,(sz||w)/2]});}
// ─── Utilities ────────────────────────────────────────────────────────────
function esc(s){return String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
function fmtTime(ms){return new Date(ms).toLocaleTimeString();}
function fmtBytes(b){if(b>1e9)return(b/1e9).toFixed(2)+' GB';if(b>1e6)return(b/1e6).toFixed(1)+' MB';if(b>1e3)return(b/1e3).toFixed(1)+' KB';return b+' B';}
function fmtUptime(s){const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),sec=s%60;return`${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}`;}
function stationKey(f){const sid=f.station_id||(f.gn_addr?f.gn_addr.replace(/:/g,''):'anon');return`${f.msg_type||'UNKNOWN'}|${sid}`;}
// ─── Map ──────────────────────────────────────────────────────────────────
const map = L.map('map').setView([51.0, 10.0], 6);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
{attribution:'© OpenStreetMap',maxZoom:19}).addTo(map);
// Location button
const LocCtrl = L.Control.extend({
onAdd(){
const d=L.DomUtil.create('div','leaflet-bar leaflet-control');
const b=L.DomUtil.create('button','locate-btn',d);
b.innerHTML='⊕'; b.title='Show my location';
L.DomEvent.on(b,'click',L.DomEvent.stopPropagation)
.on(b,'click',L.DomEvent.preventDefault)
.on(b,'click',()=>map.locate({setView:true,maxZoom:16}));
return d;
}
});
new LocCtrl({position:'topleft'}).addTo(map);
let locCircle = null;
map.on('locationfound', e => {
if(locCircle) locCircle.remove();
locCircle = L.circle(e.latlng,{radius:e.accuracy/2,color:'#58a6ff',fillOpacity:.15}).addTo(map);
});
map.on('locationerror', () => alert('Location not available.'));
// ─── Vehicle tracking ─────────────────────────────────────────────────────
const vehicles = new Map();
const TRACK_MAX = 500;
const STALE_MS = 60_000;
let firstFix = false;
function popupHtml(v){
const f=v.lastFrame, conf=typeConf(f.msg_type);
const kmh=f.speed_mps!==undefined?(f.speed_mps*3.6).toFixed(1)+' km/h':'—';
const hdg=f.heading_deg!==undefined?f.heading_deg.toFixed(1)+'°':'—';
return`<div>
<div style="font-weight:bold;color:${conf.color};margin-bottom:4px">${esc(conf.label)}</div>
<div><span class="lbl">ID</span>${esc(f.gn_addr||'—')}</div>
<div><span class="lbl">Station</span>${esc(f.station_type_label||'—')}</div>
<div><span class="lbl">Position</span>${f.lat!==undefined?f.lat.toFixed(6)+', '+f.lon.toFixed(6):'—'}</div>
<div><span class="lbl">Speed</span>${kmh}</div>
<div><span class="lbl">Heading</span>${hdg}</div>
${f.vam ? `<div><span class="lbl">VAM station</span>${esc(String(f.vam.station_id))}</div>
<div><span class="lbl">VRU type</span>${esc(String(f.vam.station_type))}</div>
<div><span class="lbl">Generation</span>${esc(String(f.vam.generation_delta_time))} ms</div>
<div><span class="lbl">Altitude</span>${f.vam.altitude_m === undefined ? '—' : esc(String(f.vam.altitude_m))+' m'}</div>
<div><span class="lbl">Acceleration</span>${f.vam.longitudinal_acceleration_mps2 === undefined ? '—' : esc(String(f.vam.longitudinal_acceleration_mps2))+' m/s²'}</div><details><summary>VAM containers</summary><pre style="max-height:220px;overflow:auto;white-space:pre-wrap">${esc(JSON.stringify(f.vam.containers || {},null,2))}</pre></details>` : ''}
<div><span class="lbl">Frames</span>${v.count}</div>
<div><span class="lbl">Last seen</span>${fmtTime(v.lastMs)}</div>
${f.secured?`<div><span class="lbl">Security</span>🔒 signed${f.security?` (${esc(String(f.security.hash))}, signer ${esc(String(f.security.signer))}${f.security.signer_id?' '+esc(String(f.security.signer_id)):''}, PSID ${esc(String(f.security.psid))}; ${f.security.verified===true?'<b style="color:#2e7d32">signature verified</b>':f.security.verified===false?'<b style="color:#c62828">SIGNATURE INVALID</b>':'not verified'}${f.security.verification?' – '+esc(String(f.security.verification)):''})`:''}</div>`:''}
</div>`;
}
function onPositionFrame(f){
if(f.lat===undefined||f.lon===undefined)return;
const conf=typeConf(f.msg_type), key=stationKey(f), ll=[f.lat,f.lon];
const track=(f.msg_type==='CAM');
let v=vehicles.get(key);
if(!v){
const line=track?L.polyline([],{color:conf.color,weight:3,opacity:.8}).addTo(map):null;
v={count:0,lastFrame:f,lastMs:Date.now(),points:[],line,
marker:L.marker(ll,{icon:mkIcon(f.msg_type,conf.color,f.heading_deg)}).addTo(map)};
v.marker.bindPopup('',{autoPan:true,maxWidth:360});
v.marker.on('popupopen',()=>v.marker.setPopupContent(popupHtml(v)));
vehicles.set(key,v);
if(!firstFix){firstFix=true;map.setView(ll,14);}
}
v.count++;v.lastFrame=f;v.lastMs=Date.now();
if(track){v.points.push(ll);if(v.points.length>TRACK_MAX)v.points.shift();v.line.setLatLngs(v.points);}
v.marker.setLatLng(ll);
v.marker.setIcon(mkIcon(f.msg_type,conf.color,f.heading_deg));
if(v.marker.isPopupOpen())v.marker.setPopupContent(popupHtml(v));
}
setInterval(()=>{
const now=Date.now();
for(const[k,v]of vehicles){
if(now-v.lastMs>STALE_MS){v.marker.remove();if(v.line)v.line.remove();vehicles.delete(k);}
}
},10_000);
// ─── Station log model ────────────────────────────────────────────────────
const stations = new Map();
const MAX_STATIONS = 150;
const FRAME_HISTORY = 20;
function updateStation(f){
const key=stationKey(f);
let s=stations.get(key);
if(!s){
s={key,msgType:f.msg_type||'UNKNOWN',stationId:f.station_id,gnAddr:f.gn_addr,
frameCount:0,lastSeen:0,lastFrame:f,recentFrames:[],expanded:false,secured:null};
stations.set(key,s);
if(stations.size>MAX_STATIONS){
let ok=null,ot=Infinity;
for(const[k,v]of stations)if(v.lastSeen<ot){ok=k;ot=v.lastSeen;}
if(ok)stations.delete(ok);
}
}
s.frameCount++;s.lastSeen=Date.now();s.lastFrame=f;
if(f.secured!==undefined)s.secured=f.secured;
s.recentFrames.unshift(f);
if(s.recentFrames.length>FRAME_HISTORY)s.recentFrames.pop();
}
// ─── MAC analysis model ───────────────────────────────────────────────────
const macEntries = new Map(); // mac → {mac, firstSeen, lastSeen, count, types, lastLat, lastLon, stationType}
let macSortCol='last', macSortAsc=false;
function updateMac(f){
const mac=f.gn_addr; if(!mac)return;
let e=macEntries.get(mac);
const now=Date.now();
if(!e){e={mac,firstSeen:now,lastSeen:now,count:0,types:new Set(),lastLat:null,lastLon:null,stationType:'—'};macEntries.set(mac,e);}
e.lastSeen=now;e.count++;
if(f.msg_type)e.types.add(f.msg_type);
if(f.lat!==undefined){e.lastLat=f.lat;e.lastLon=f.lon;}
if(f.station_type_label)e.stationType=f.station_type_label;
}
// ─── Sparkline (rate history, one value per second) ───────────────────────
const sparkData=[]; // last 60 per-second counts
let sparkBucket=0, lastSparkSec=Math.floor(Date.now()/1000);
function tickSparkline(now){
const s=Math.floor(now/1000);
if(s!==lastSparkSec){
sparkData.push(sparkBucket); sparkBucket=0; lastSparkSec=s;
if(sparkData.length>60)sparkData.shift();
renderSparkline();
} else sparkBucket++;
}
function renderSparkline(){
const el=document.getElementById('sparkline'); if(!el)return;
const max=Math.max(1,...sparkData);
el.innerHTML=sparkData.map(v=>`<div class="spark-bar" style="height:${Math.max(4,Math.round(v/max*44))}px" title="${v}/s"></div>`).join('');
}
// ─── Type counts ─────────────────────────────────────────────────────────
const typeCounts={};
function updateTypeCounts(t){typeCounts[t]=(typeCounts[t]||0)+1;}
function renderTypeCounts(){
const tbody=document.getElementById('type-tbody'); if(!tbody)return;
const total=Object.values(typeCounts).reduce((a,b)=>a+b,0)||1;
const rows=Object.entries(typeCounts).sort((a,b)=>b[1]-a[1]).map(([t,n])=>{
const conf=typeConf(t);
return`<tr>
<td style="color:${conf.color};font-weight:700">${esc(conf.short)}</td>
<td style="color:#8b949e">${esc(conf.label)}</td>
<td>${n} <span style="color:#555">(${(n/total*100).toFixed(1)}%)</span></td>
</tr>`;
}).join('');
tbody.innerHTML=rows||'<tr><td colspan="3" style="color:#555;padding:8px">Noch keine Daten.</td></tr>';
}
// ─── Filter ───────────────────────────────────────────────────────────────
const filterActive=new Set(['CAM','DENM','SPATEM','MAPEM','VAM','OTHER']);
function isVisible(t){return PRIMARY.includes(t)?filterActive.has(t):filterActive.has('OTHER');}
document.querySelectorAll('.fchip').forEach(b=>{
b.addEventListener('click',()=>{
const t=b.dataset.type;
filterActive.has(t)?filterActive.delete(t):filterActive.add(t);
b.classList.toggle('on',filterActive.has(t));
renderStationList();
});
});
// ─── Tab switching ────────────────────────────────────────────────────────
document.querySelectorAll('.tab').forEach(tab=>{
tab.addEventListener('click',()=>{
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
document.querySelectorAll('.pane').forEach(p=>p.classList.remove('active'));
tab.classList.add('active');
document.getElementById('pane-'+tab.dataset.tab).classList.add('active');
if(tab.dataset.tab==='stats'){renderTypeCounts();renderSparkline();}
if(tab.dataset.tab==='macs'){renderMacTable();}
});
});
// ─── Log rendering ────────────────────────────────────────────────────────
function buildSummary(s){
const f=s.lastFrame,cnt=`${s.frameCount}×`;
let d='';
switch(s.msgType){
case'VAM':d=f.vam ? `VRU ${f.station_type_label || f.vam.station_type} ${f.vam.speed_mps === undefined ? '—' : (f.vam.speed_mps*3.6).toFixed(1)+' km/h'} t=${f.vam.generation_delta_time} ms` : (f.vam_status || 'VAM payload unavailable');break;
case'CAM':{const ps=[];
if(f.speed_mps!==undefined&&f.speed_mps>0.5)ps.push((f.speed_mps*3.6).toFixed(0)+' km/h');
if(f.heading_deg!==undefined)ps.push(['N','NE','E','SE','S','SW','W','NW'][Math.round(f.heading_deg/45)%8]);
if(f.lat!==undefined)ps.push(f.lat.toFixed(4)+','+f.lon.toFixed(4));
d=ps.join(' ')||'—';break;}
case'SPATEM':d='Signal phase';break;
case'DENM':d='Hazard warning';break;
case'MAPEM':d='Intersection map';break;
default:d=f.len!==undefined?`len=${f.len}`:'—';
}
return`${cnt} ${d}`;
}
function etCls(et){if(et===0x8947)return'et-its';if(et==null)return'et-unk';return'et-wlan';}
function etLbl(et){if(et===0x8947)return'ITS-G5';if(et==null)return'?';return'0x'+et.toString(16).padStart(4,'0');}
const timeFmt=new Intl.DateTimeFormat(undefined,{hour:'2-digit',minute:'2-digit',second:'2-digit'});
function renderStationList(){
const now=Date.now();
const el=document.getElementById('station-list');
const sorted=[...stations.values()].filter(s=>isVisible(s.msgType)).sort((a,b)=>b.lastSeen-a.lastSeen);
if(!sorted.length){el.innerHTML='<div id="empty-log">Waiting for frames...</div>';return;}
let html='';
for(const s of sorted){
const conf=typeConf(s.msgType);
const stale=(now-s.lastSeen)>30_000;
const sid=s.stationId?'#'+s.stationId.slice(-8):'—';
const t=timeFmt.format(new Date(s.lastSeen));
const lock=s.secured===true?'🔒':'';
const chev=s.expanded?'▾':'▸';
html+=`<div class="srow${stale?' stale':''}" data-key="${esc(s.key)}">
<div class="srow-icon">${typeSvg(s.msgType,conf.color,s.lastFrame?.heading_deg)}</div>
<div class="srow-badge" style="color:${conf.color}">${esc(conf.short)}</div>
<div class="srow-id">${esc(sid)}</div>
<div class="srow-sum">${esc(buildSummary(s))}</div>
<div class="srow-time">${esc(t)}</div>
<div class="srow-count">${s.frameCount}×</div>
<div class="srow-lock">${lock}</div>
<div class="srow-chev">${chev}</div>
</div>`;
if(s.expanded){
for(const f of s.recentFrames){
const ft=timeFmt.format(new Date((f.sec||0)*1000+Math.floor((f.usec||0)/1000)));
html+=`<div class="frow">
<div class="frow-time">${esc(ft)}</div>
<div class="frow-et ${etCls(f.ether_type)}">${etLbl(f.ether_type)}</div>
<div class="frow-len">${f.len||0}</div>
<div class="frow-hex">${esc(f.hex||'')}</div>
</div>`;
}
}
}
el.innerHTML=html;
el.querySelectorAll('.srow[data-key]').forEach(row=>
row.addEventListener('click',()=>{
const s=stations.get(row.dataset.key);
if(s){s.expanded=!s.expanded;renderStationList();}
})
);
}
// ─── MAC table ────────────────────────────────────────────────────────────
function sortMac(col){
if(macSortCol===col)macSortAsc=!macSortAsc;else{macSortCol=col;macSortAsc=false;}
renderMacTable();
}
function renderMacTable(){
const filter=(document.getElementById('mac-search')?.value||'').toLowerCase();
const tbody=document.getElementById('mac-tbody'); if(!tbody)return;
let rows=[...macEntries.values()];
if(filter)rows=rows.filter(e=>e.mac.includes(filter)||[...e.types].join().toLowerCase().includes(filter));
rows.sort((a,b)=>{
let av,bv;
switch(macSortCol){
case'mac':av=a.mac;bv=b.mac;break;
case'type':av=[...a.types].join();bv=[...b.types].join();break;
case'station':av=a.stationType;bv=b.stationType;break;
case'first':av=a.firstSeen;bv=b.firstSeen;break;
case'count':av=a.count;bv=b.count;break;
default:av=a.lastSeen;bv=b.lastSeen;
}
return macSortAsc?(av>bv?1:-1):(av<bv?1:-1);
});
if(!rows.length){tbody.innerHTML='<tr><td colspan="7" id="mac-empty">No entries.</td></tr>';return;}
tbody.innerHTML=rows.map(e=>{
const types=[...e.types].map(t=>`<span style="color:${typeConf(t).color}">${typeConf(t).short}</span>`).join(' ');
const pos=e.lastLat!==null?`${e.lastLat.toFixed(5)}, ${e.lastLon.toFixed(5)}`:'—';
return`<tr>
<td class="mac-addr">${esc(e.mac)}</td>
<td class="mac-types">${types}</td>
<td style="color:#8b949e">${esc(e.stationType)}</td>
<td style="color:#555">${fmtTime(e.firstSeen)}</td>
<td style="color:#555">${fmtTime(e.lastSeen)}</td>
<td style="color:#58a6ff">${e.count}</td>
<td style="color:#555">${esc(pos)}</td>
</tr>`;
}).join('');
}
function exportMacCsv(){
const rows=[['MAC','Typen','Station','Erstes Signal','Letztes Signal','Frames','Lat','Lon']];
for(const e of macEntries.values()){
rows.push([e.mac,[...e.types].join(';'),e.stationType,
new Date(e.firstSeen).toISOString(),new Date(e.lastSeen).toISOString(),
e.count,e.lastLat??'',e.lastLon??'']);
}
const csv=rows.map(r=>r.map(v=>`"${String(v).replace(/"/g,'""')}"`).join(',')).join('\n');
const a=document.createElement('a');
a.href='data:text/csv;charset=utf-8,'+encodeURIComponent(csv);
a.download=`macs_${new Date().toISOString().slice(0,19).replace(/:/g,'-')}.csv`;
a.click();
}
// ─── Batch log refresh ────────────────────────────────────────────────────
const pendingFrames=[]; let logDirty=false;
function scheduleLog(){
if(logDirty)return; logDirty=true;
setTimeout(()=>{
logDirty=false;
if(pendingFrames.length){
const batch=pendingFrames.splice(0);
for(const f of batch){updateStation(f);updateMac(f);updateTypeCounts(f.msg_type||'UNKNOWN');}
}
renderStationList();
},300);
}
// ─── Header elements ──────────────────────────────────────────────────────
const elNode=document.getElementById('node');
const elTotal=document.getElementById('ntotal');
const elRate=document.getElementById('nrate');
const elBytes=document.getElementById('nbytes');
const elLast=document.getElementById('last');
const elSerial=document.getElementById('serial-dot');
const elSerLbl=document.getElementById('serial-lbl');
const connEl=document.getElementById('conn');
const mqttBtn=document.getElementById('mqtt-toggle');
const mqttTxt=document.getElementById('mqtt-state-text');
let total=0,lastBytesN=0,lastBytesT=Date.now();
const recent=[];
// ─── MQTT toggle ──────────────────────────────────────────────────────────
let mqttState={enabled:false,available:false};
function renderMqttState(){
const on=mqttState.enabled&&mqttState.available;
mqttBtn.classList.toggle('on',on);
mqttBtn.disabled=!mqttState.available;
mqttTxt.textContent=!mqttState.available?'—':mqttState.enabled?'on':'off';
mqttBtn.title=!mqttState.available?'No broker connected.':
mqttState.enabled?'Frames werden übertragen. Klicken zum Deaktivieren.':
'Frames bleiben lokal. Klicken zum Aktivieren.';
}
mqttBtn.addEventListener('click',()=>{
if(!mqttState.available)return;
mqttBtn.disabled=true;
fetch('/api/mqtt',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({enabled:!mqttState.enabled})})
.then(r=>r.json()).then(s=>{mqttState=s;renderMqttState();})
.catch(()=>{mqttBtn.disabled=false;});
});
// ─── Broker status ────────────────────────────────────────────────────────
function renderBrokers(list){
const el=document.getElementById('broker-list'); if(!el)return;
if(!list||!list.length){el.innerHTML='<span style="color:#555;font-size:12px">No broker configured.</span>';return;}
el.innerHTML=list.map(b=>`<div class="broker-row">
<div class="broker-dot${b.connected?' up':''}"></div>
<span style="font-size:12px">${esc(b.url||'?')}</span>
<span style="color:#555;font-size:11px">${b.connected?'connected':'disconnected'}</span>
</div>`).join('');
}
// ─── PCAP recording ───────────────────────────────────────────────────────
let recState={recording:false,file:'',frames:0};
function renderRecState(s){
recState=s||recState;
const el=document.getElementById('rec-status');
const recBtn=document.getElementById('rec-btn');
const startBtn=document.getElementById('rec-start-btn');
const stopBtn=document.getElementById('rec-stop-btn');
const dlBtn=document.getElementById('rec-dl-btn');
if(recState.recording){
if(el)el.innerHTML=`<span style="color:#f47979">⏺ Recording active</span> ${esc(recState.file)} <b>${recState.frames}</b> frames`;
if(recBtn){recBtn.classList.add('rec');recBtn.textContent='⏹ Recording...';}
if(startBtn)startBtn.disabled=true;
if(stopBtn)stopBtn.disabled=false;
if(dlBtn)dlBtn.style.display='none';
} else {
const info=recState.frames>0?`Last recording: ${esc(recState.file||'—')} (${recState.frames} frames)`:'No active recording.';
if(el)el.innerHTML=`<span style="color:#555">${info}</span>`;
if(recBtn){recBtn.classList.remove('rec');recBtn.textContent='⏺ Record';}
if(startBtn)startBtn.disabled=false;
if(stopBtn)stopBtn.disabled=true;
if(dlBtn)dlBtn.style.display=(recState.frames>0)?'inline-block':'none';
}
}
function startRecording(filename){
fetch('/api/record',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({action:'start',file:filename||''})})
.then(r=>r.json()).then(renderRecState).catch(console.error);
}
function stopRecording(){
fetch('/api/record',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({action:'stop'})})
.then(r=>r.json()).then(renderRecState).catch(console.error);
}
document.getElementById('rec-btn').addEventListener('click',()=>{
recState.recording?stopRecording():startRecording();
});
// ─── SSE ──────────────────────────────────────────────────────────────────
function connect(){
const es=new EventSource('/events');
es.onopen=()=>{connEl.textContent='connected';connEl.className='up';};
es.onerror=()=>{connEl.textContent='reconnecting...';connEl.className='down';};
es.addEventListener('hello',e=>{
const d=JSON.parse(e.data);
elNode.textContent=d.node_id||'?';
if(d.mqtt){mqttState=d.mqtt;renderMqttState();}
if(d.serial)applySerial(d.serial);
if(d.stats)applyStats(d.stats);
if(d.brokers)renderBrokers(d.brokers);
if(d.record)renderRecState(d.record);
});
es.addEventListener('frame',e=>{
const f=JSON.parse(e.data);
total++;
const now=Date.now();
elTotal.textContent=total;
elLast.textContent=new Date(now).toLocaleTimeString();
recent.push(now);
while(recent.length&&recent[0]<now-60_000)recent.shift();
elRate.textContent=recent.length;
tickSparkline(now);
if(f.lat!==undefined&&f.lon!==undefined)onPositionFrame(f);
pendingFrames.push(f);
scheduleLog();
});
es.addEventListener('stats',e=>applyStats(JSON.parse(e.data)));
es.addEventListener('mqtt_state',e=>{mqttState=JSON.parse(e.data);renderMqttState();});
es.addEventListener('serial_state',e=>applySerial(JSON.parse(e.data)));
es.addEventListener('broker_status',e=>renderBrokers(JSON.parse(e.data)));
es.addEventListener('record_state',e=>renderRecState(JSON.parse(e.data)));
}
function applyStats(s){
if(!s||!Object.keys(s).length)return;
document.getElementById('st-frames').textContent=s.frames||0;
document.getElementById('st-rate').textContent=(s.rate||0)+' /min';
if(s.freq_hz!==undefined&&s.freq_hz>0){
document.getElementById('st-freq').textContent=s.freq_hz.toFixed(2)+' Hz';
} else {
document.getElementById('st-freq').textContent='-';
}
if(s.mean_interval_ms!==undefined&&s.mean_interval_ms>0){
document.getElementById('st-interval').textContent=s.mean_interval_ms.toFixed(1)+' ms ± '+s.jitter_ms.toFixed(2)+' ms';
document.getElementById('st-range').textContent='['+s.min_interval_ms.toFixed(1)+' ms – '+s.max_interval_ms.toFixed(1)+' ms]';
} else {
document.getElementById('st-interval').textContent='-';
document.getElementById('st-range').textContent='-';
}
document.getElementById('st-bytes').textContent=fmtBytes(s.bytes||0);
if(s.uptime_s!==undefined)document.getElementById('st-uptime').textContent=fmtUptime(s.uptime_s);
const now=Date.now(),dt=(now-lastBytesT)/1000;
if(dt>0&&s.bytes!==undefined){
const bps=(s.bytes-lastBytesN)/dt;
elBytes.textContent=bps>1024?(bps/1024).toFixed(1)+' KB/s':bps.toFixed(0)+' B/s';
lastBytesN=s.bytes;lastBytesT=now;
}
}
function applySerial(s){
const up=s&&s.connected;
elSerial.className=up?'up':'down';
elSerLbl.textContent=up?(s.port||'USB'):'disconnected';
}
renderMqttState();
renderRecState();
connect();
</script>
</body>
</html>
@@ -0,0 +1,289 @@
"""
Local HTTP+SSE dashboard server for the ITS-G5 bridge (V2X2MAP).
Routes:
GET / → dashboard.html
GET /events → SSE stream
GET /api/mqtt → MQTT toggle state
GET /api/stats → last stats snapshot
GET /api/record → PCAP recording state
GET /api/brokers → configured broker list + status
POST /api/mqtt → toggle MQTT publishing {"enabled": bool}
POST /api/record → start/stop recording {"action": "start"|"stop", "file": "optional.pcap"}
Pure stdlib: http.server + threading + queue.
"""
from __future__ import annotations
import collections
import json
import logging
import os
import queue
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
_HERE = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)))
_HTML_PATH = os.path.join(_HERE, "dashboard.html")
_HISTORY_MAX = 200
_subs_lock = threading.Lock()
_subscribers: list[queue.Queue] = []
_history: collections.deque[bytes] = collections.deque(maxlen=_HISTORY_MAX)
_node_id = "?"
# MQTT state
_mqtt_enabled = False # off by default — user activates from dashboard
_mqtt_available = False
# Broker status {url: {"host": h, "port": p, "connected": bool}}
_broker_status: dict[str, dict] = {}
# Serial state
_serial_connected = False
_serial_port = ""
# Stats
_last_stats: dict = {}
# PCAP recording state (updated by bridge via set_record_callback)
_record_state: dict = {"recording": False, "file": "", "frames": 0}
_record_callback = None # callable(action: str, filename: str) → dict
# ---------------------------------------------------------------------------
# Public API used by the bridge
# ---------------------------------------------------------------------------
def is_mqtt_enabled() -> bool:
return _mqtt_enabled and _mqtt_available
def get_mqtt_state() -> dict:
return {"enabled": _mqtt_enabled, "available": _mqtt_available}
def set_mqtt_state(enabled: bool | None = None, available: bool | None = None) -> dict:
global _mqtt_enabled, _mqtt_available
if available is not None:
_mqtt_available = bool(available)
if enabled is not None:
_mqtt_enabled = bool(enabled)
_broadcast(_format_event("mqtt_state", get_mqtt_state()), persist=False)
return get_mqtt_state()
def register_brokers(urls: list[str]) -> None:
"""Call once at startup with the configured broker URLs."""
global _broker_status
_broker_status = {}
for url in urls:
_broker_status[url] = {"url": url, "connected": False}
def update_broker_status(host: str, port: int, connected: bool) -> None:
for entry in _broker_status.values():
if host in entry["url"] and str(port) in entry["url"]:
entry["connected"] = connected
_broadcast(_format_event("broker_status", list(_broker_status.values())), persist=False)
def broadcast_frame(frame: dict) -> None:
_broadcast(_format_event("frame", frame), persist=True)
def broadcast_stats(stats: dict) -> None:
global _last_stats
_last_stats = stats
_broadcast(_format_event("stats", stats), persist=False)
def broadcast_serial_state(connected: bool, port: str = "") -> None:
global _serial_connected, _serial_port
_serial_connected = connected
_serial_port = port
_broadcast(_format_event("serial_state", {"connected": connected, "port": port}),
persist=False)
def set_record_callback(fn) -> None:
global _record_callback
_record_callback = fn
def update_record_state(state: dict) -> None:
global _record_state
_record_state = state
_broadcast(_format_event("record_state", state), persist=False)
def get_record_state() -> dict:
return _record_state
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _format_event(name: str, data) -> bytes:
payload = json.dumps(data, separators=(",", ":"))
return f"event: {name}\ndata: {payload}\n\n".encode("utf-8")
def _broadcast(msg: bytes, persist: bool) -> None:
with _subs_lock:
if persist:
_history.append(msg)
dead = []
for q in _subscribers:
try:
q.put_nowait(msg)
except queue.Full:
dead.append(q)
for q in dead:
_subscribers.remove(q)
# ---------------------------------------------------------------------------
# HTTP handler
# ---------------------------------------------------------------------------
class _Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
logging.debug("dashboard %s - %s", self.address_string(), fmt % args)
def do_GET(self): # noqa: N802
if self.path in ("/", "/index.html"):
self._serve_html()
elif self.path == "/events":
self._serve_sse()
elif self.path == "/api/mqtt":
self._serve_json(get_mqtt_state())
elif self.path == "/api/stats":
self._serve_json(_last_stats)
elif self.path == "/api/record":
self._serve_json(_record_state)
elif self.path == "/api/record/download":
fn = _record_state.get("file")
if fn and os.path.isfile(fn):
self.send_response(200)
self.send_header("Content-Type", "application/vnd.tcpdump.pcap")
self.send_header("Content-Disposition", f'attachment; filename="{os.path.basename(fn)}"')
self.send_header("Content-Length", str(os.path.getsize(fn)))
self.end_headers()
with open(fn, "rb") as f:
self.wfile.write(f.read())
else:
self.send_error(404, "no recording available")
elif self.path == "/api/brokers":
self._serve_json(list(_broker_status.values()))
else:
self.send_error(404)
def do_POST(self): # noqa: N802
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length) if length > 0 else b"{}"
try:
body = json.loads(raw.decode("utf-8") or "{}")
except (UnicodeDecodeError, json.JSONDecodeError):
self.send_error(400, "invalid JSON"); return
if self.path == "/api/mqtt":
if "enabled" not in body:
self.send_error(400, "missing 'enabled'"); return
self._serve_json(set_mqtt_state(enabled=bool(body["enabled"])))
elif self.path == "/api/record":
action = body.get("action", "")
filename = body.get("file", "")
if action not in ("start", "stop"):
self.send_error(400, "action must be 'start' or 'stop'"); return
if _record_callback:
result = _record_callback(action, filename)
self._serve_json(result)
else:
self.send_error(503, "recording not available")
else:
self.send_error(404)
def _serve_json(self, data):
body = json.dumps(data).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self.wfile.write(body)
def _serve_html(self):
try:
with open(_HTML_PATH, "rb") as f:
body = f.read()
except OSError:
self.send_error(500, "dashboard.html not found"); return
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self.wfile.write(body)
def _serve_sse(self):
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.send_header("Connection", "keep-alive")
self.send_header("X-Accel-Buffering", "no")
self.end_headers()
q: queue.Queue = queue.Queue(maxsize=1000)
with _subs_lock:
backlog = list(_history)
_subscribers.append(q)
try:
self.wfile.write(_format_event("hello", {
"node_id": _node_id,
"mqtt": get_mqtt_state(),
"serial": {"connected": _serial_connected, "port": _serial_port},
"stats": _last_stats,
"brokers": list(_broker_status.values()),
"record": _record_state,
}))
for msg in backlog:
self.wfile.write(msg)
self.wfile.flush()
while True:
try:
msg = q.get(timeout=15)
except queue.Empty:
self.wfile.write(b": ping\n\n")
self.wfile.flush()
continue
self.wfile.write(msg)
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError):
pass
finally:
with _subs_lock:
if q in _subscribers:
_subscribers.remove(q)
# ---------------------------------------------------------------------------
# Server startup
# ---------------------------------------------------------------------------
def start(port: int, node_id: str = "?") -> ThreadingHTTPServer:
global _node_id
_node_id = node_id
server = ThreadingHTTPServer(("127.0.0.1", port), _Handler)
t = threading.Thread(target=server.serve_forever, name="dashboard-http", daemon=True)
t.start()
logging.info("dashboard listening on http://127.0.0.1:%d", port)
return server
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,43 @@
# -*- mode: python ; coding: utf-8 -*-
# Bridge only — no wizard, no esptool, no tkinter → smaller executable.
datas = [('dashboard.html', '.'), ('asn1', 'asn1')]
binaries = []
hiddenimports = ['dashboard_server']
a = Analysis(
['its_g5_bridge.py'],
pathex=[],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=['tkinter', '_tkinter', 'esptool', 'setup_wizard', 'setup_main'],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='its-g5-bridge',
icon='icons/its-g5-bridge.ico',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
@@ -0,0 +1,69 @@
# -*- mode: python ; coding: utf-8 -*-
# macOS build of the V2X2MAP setup/flash tool → dist/V2X2MAP Setup.app
# Build: ~/.local/py312/bin/pyinstaller --noconfirm its-g5-setup-mac.spec
# (needs a python with a working Tk ≥8.6 — the Xcode CLT python's Tk 8.5 renders blank)
from PyInstaller.utils.hooks import collect_all
datas = [
('firmware/bootloader.bin', 'firmware'),
('firmware/partition-table.bin', 'firmware'),
('firmware/ota_data_initial.bin', 'firmware'),
('firmware/firmware.bin', 'firmware'),
]
binaries = []
hiddenimports = ['setup_wizard']
tmp_ret = collect_all('esptool')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
a = Analysis(
['setup_main.py'],
pathex=[],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='its-g5-setup',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=False,
name='its-g5-setup',
)
app = BUNDLE(
coll,
name='V2X2MAP Setup.app',
icon='icons/its-g5-setup.icns',
bundle_identifier='com.v2x2map.setup',
info_plist={
'CFBundleDisplayName': 'V2X2MAP Setup',
'CFBundleShortVersionString': '1.0.0',
'NSHighResolutionCapable': True,
},
)
@@ -0,0 +1,52 @@
# -*- mode: python ; coding: utf-8 -*-
# Setup wizard + flash tool — includes esptool and firmware binaries.
from PyInstaller.utils.hooks import collect_all
datas = [
('firmware/bootloader.bin', 'firmware'),
('firmware/partition-table.bin', 'firmware'),
('firmware/ota_data_initial.bin', 'firmware'),
('firmware/firmware.bin', 'firmware'),
]
binaries = []
hiddenimports = ['setup_wizard']
tmp_ret = collect_all('esptool')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
a = Analysis(
['setup_main.py'],
pathex=[],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='its-g5-setup',
icon='icons/its-g5-setup.ico',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False, # GUI-only, no console window
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
@@ -0,0 +1,825 @@
"""
ITS-G5 Receiver USB-to-MQTT bridge (V2X2MAP).
Reads framed packets from an ESP32-C5 connected via USB-Serial-JTAG and
republishes them to one or more MQTT brokers.
Frame format on the wire (little-endian, no padding):
magic[4] = b"ITS5"
sec u32 epoch seconds (from rx_ctrl)
usec u32 microseconds
len u16 payload length in bytes
payload <len> raw 802.11 MAC frame
Run:
python its_g5_bridge.py --node-id V2X2MAP:d2cf13ed6293
python its_g5_bridge.py --node-id V2X2MAP:d2cf13ed6293 --broker mqtts://broker1:8883 --broker mqtt://broker2:1883
"""
import argparse
import json
import math
import logging
import os
import re
import ssl
import struct
import sys
import threading
import time
from collections import deque
from urllib.parse import urlparse
import paho.mqtt.client as mqtt
import serial
import serial.tools.list_ports
import dashboard_server
from vam import decode_vam
from secured import unwrap_signed
# Signature verification (verify.py), set by --trust. None: signatures are not checked at all.
TRUST = None
MAGIC = b"ITS5"
HEADER_FMT = "<4sIIH"
HEADER_LEN = struct.calcsize(HEADER_FMT) # 14
DEFAULT_BROKER = "mqtts://cits1.opentrafficmap.org:8883"
ESP_VID = 0x303A # Espressif Systems
FALLBACK_VIDS = {0x10C4, 0x1A86} # CP210x, CH340
HDR_LENGTHS = (24, 26, 30, 32)
MSG_TYPE_FROM_BTP = {
2001: "CAM",
2002: "DENM",
2003: "MAPEM",
2004: "SPATEM",
2006: "IVIM",
2007: "SREM",
2008: "SSEM",
2010: "TLM",
2012: "RTCMEM",
2018: "VAM",
}
STATION_TYPES = {
0: "unknown", 1: "pedestrian", 2: "cyclist", 3: "moped",
4: "motorcycle", 5: "passenger car", 6: "bus", 7: "light truck",
8: "heavy truck", 9: "trailer", 10: "special vehicle", 11: "tram", 15: "RSU",
}
# ---------------------------------------------------------------------------
# Frame decoding
# ---------------------------------------------------------------------------
def sniff_ether_type(payload: bytes) -> int | None:
for hdr_len in HDR_LENGTHS:
if len(payload) < hdr_len + 8:
continue
snap = payload[hdr_len:hdr_len + 8]
if snap[:6] == b"\xaa\xaa\x03\x00\x00\x00":
return (snap[6] << 8) | snap[7]
return None
def decode_itsg5_full(payload: bytes) -> dict:
"""
Full ITS-G5 decode — mirrors Android ItsG5Decoder.decodeFull().
Returns dict with ether_type, msg_type, station_id (hex str),
gn_addr, station_type[_label], secured, and optionally
lat/lon/speed_mps/heading_deg.
"""
et = sniff_ether_type(payload)
result: dict = {"ether_type": et, "msg_type": "UNKNOWN"}
if et != 0x8947:
return result
for hdr_len in HDR_LENGTHS:
if len(payload) < hdr_len + 8 + 4 + 8:
continue
if payload[hdr_len:hdr_len + 6] != b"\xaa\xaa\x03\x00\x00\x00":
continue
basic_off = hdr_len + 8
common_off = basic_off + 4
if len(payload) < common_off + 8:
continue
security = None
if (payload[basic_off] & 0x0F) == 2:
# Secured packet (TS 103 097): the Common Header and everything after it are the
# unsecuredData inside the Ieee1609Dot2Data. Take the payload out of the envelope
# and continue on a buffer that carries the basic header followed by that payload.
# The signature is NOT verified here: fields are what the sender claims.
envelope = payload[common_off:]
try:
security = unwrap_signed(envelope)
except ValueError as exc:
result.update(secured=True, decode_status=f"secured GN envelope: {exc}")
return result
payload = payload[:common_off] + security["payload"]
if len(payload) < common_off + 8:
result.update(secured=True, decode_status="secured GN envelope: payload too short")
return result
ht_hst = payload[common_off + 1]
ht = (ht_hst >> 4) & 0x0F
hst = ht_hst & 0x0F
ext_hdr_len = {1: 4, 2: 48, 3: 56, 4: 44, 5: 28, 6: 36}.get(ht, 28)
src_in_ext = 0 if (ht == 5 and hst == 0) else 4
src_off = common_off + 8 + src_in_ext
btp_off = common_off + 8 + ext_hdr_len
if len(payload) < btp_off + 4 or len(payload) < src_off + 24:
continue
gn_addr = payload[src_off:src_off + 8]
st = (gn_addr[0] >> 2) & 0x1F
mac = gn_addr[2:8]
station_id = int.from_bytes(gn_addr, "big")
lat_raw = int.from_bytes(payload[src_off + 12:src_off + 16], "big", signed=True)
lon_raw = int.from_bytes(payload[src_off + 16:src_off + 20], "big", signed=True)
psh_off = src_off + 20
if not (-90.0 <= lat_raw / 1e7 <= 90.0) and len(payload) >= src_off + 28:
lat2 = int.from_bytes(payload[src_off + 16:src_off + 20], "big", signed=True)
lon2 = int.from_bytes(payload[src_off + 20:src_off + 24], "big", signed=True)
if -90.0 <= lat2 / 1e7 <= 90.0 and -180.0 <= lon2 / 1e7 <= 180.0:
lat_raw, lon_raw, psh_off = lat2, lon2, src_off + 24
psh_raw = int.from_bytes(payload[psh_off:psh_off + 4], "big")
speed_raw = (psh_raw >> 16) & 0x7FFF
if speed_raw >= 0x4000:
speed_raw -= 0x8000
heading_raw = psh_raw & 0xFFFF
lat = lat_raw / 1e7
lon = lon_raw / 1e7
dst_port = (payload[btp_off] << 8) | payload[btp_off + 1]
msg_type = MSG_TYPE_FROM_BTP.get(dst_port, "UNKNOWN")
secured = security is not None
result.update({
"msg_type": msg_type,
"station_id": format(station_id, "016x"),
"gn_addr": ":".join(f"{b:02x}" for b in mac),
"station_type": st,
"station_type_label": STATION_TYPES.get(st, f"type {st}"),
"secured": secured,
"btp_dst_port": dst_port,
})
if secured:
result["security"] = {
"psid": security["psid"],
"hash": security["hash_id"],
"signer": security["signer"],
"signer_id": security["signer_id"],
"generation_time_ms": security["generation_time_ms"],
# True: signature valid under a --trust chain; False: it fails; None: not checked
# (no --trust, or the signer does not chain to anything configured).
"verified": None,
}
if TRUST is not None:
verdict = TRUST.verify(envelope)
result["security"]["verified"] = verdict["verified"]
result["security"]["verification"] = verdict["reason"]
if -90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0 and not (lat == 0.0 and lon == 0.0):
result["lat"] = lat
result["lon"] = lon
result["speed_mps"] = speed_raw * 0.01
if heading_raw != 0xFFFF:
result["heading_deg"] = heading_raw * 0.1
if msg_type == "VAM":
# Never use GN values as substitutes for missing/invalid VAM data.
for key in ("lat", "lon", "speed_mps", "heading_deg"):
result.pop(key, None)
length = int.from_bytes(payload[common_off + 4:common_off + 6], "big")
nh = payload[common_off] >> 4
try:
if nh not in (1, 2):
raise ValueError("non-BTP VAM transport unsupported")
if length < 4 or btp_off + length > len(payload):
raise ValueError("truncated GN payload")
vam = decode_vam(payload[btp_off + 4:btp_off + length])
result["vam"] = vam
result["vam_status"] = "decoded"
result["station_type"] = vam["station_type"]
result["station_type_label"] = STATION_TYPES.get(vam["station_type"], str(vam["station_type"]))
for key in ("lat", "lon", "speed_mps", "heading_deg"):
if key in vam:
result[key] = vam[key]
except ValueError as exc:
result["vam_status"] = str(exc)
return result
return result
# ---------------------------------------------------------------------------
# PCAP writer
# ---------------------------------------------------------------------------
class PcapWriter:
"""Minimal libpcap writer — DLT_IEEE802_11 (105), open in Wireshark directly."""
_GLOBAL = struct.pack("<IHHiIII", 0xA1B2C3D4, 2, 4, 0, 0, 65535, 105)
def __init__(self, path: str):
self._f = open(path, "wb")
self._f.write(self._GLOBAL)
self._f.flush()
def write(self, sec: int, usec: int, data: bytes):
n = len(data)
self._f.write(struct.pack("<IIII", sec, usec, n, n))
self._f.write(data)
self._f.flush()
def close(self):
try: self._f.close()
except Exception: pass
# ---------------------------------------------------------------------------
# Frame parser
# ---------------------------------------------------------------------------
class FrameReader:
"""Stateful resync parser: scans a byte stream for ITS5 frames."""
def __init__(self):
self._buf = bytearray()
def feed(self, chunk: bytes):
self._buf.extend(chunk)
while True:
idx = self._buf.find(MAGIC)
if idx < 0:
if len(self._buf) > 3:
del self._buf[: len(self._buf) - 3]
return
if idx > 0:
del self._buf[:idx]
if len(self._buf) < HEADER_LEN:
return
_, sec, usec, plen = struct.unpack_from(HEADER_FMT, self._buf, 0)
if plen > 4096:
del self._buf[:4]
continue
if len(self._buf) < HEADER_LEN + plen:
return
payload = bytes(self._buf[HEADER_LEN: HEADER_LEN + plen])
del self._buf[: HEADER_LEN + plen]
yield sec, usec, payload
# ---------------------------------------------------------------------------
# Serial helpers
# ---------------------------------------------------------------------------
def _find_esp_port() -> str | None:
for p in serial.tools.list_ports.comports():
if p.vid == ESP_VID:
return p.device
for p in serial.tools.list_ports.comports():
if p.vid in FALLBACK_VIDS:
return p.device
return None
def _open_serial(port: str, baud: int) -> serial.Serial:
ser = serial.Serial(port, baud, timeout=0.5)
ser.dtr = False
ser.rts = False
time.sleep(0.05)
return ser
# ---------------------------------------------------------------------------
# MQTT helpers — multiple brokers
# ---------------------------------------------------------------------------
def _parse_broker(url: str) -> tuple[str, int, bool]:
"""Return (host, port, use_tls) from a broker URL."""
url = url.strip()
if "://" not in url:
url = "mqtts://" + url
parsed = urlparse(url)
tls = parsed.scheme in ("mqtts", "ssl")
host = parsed.hostname or "localhost"
port = parsed.port or (8883 if tls else 1883)
return host, port, tls
def build_mqtt_clients(node_id: str, broker_urls: list[str], status_topic: str,
connected_count: list, count_lock: threading.Lock):
"""
Build one Paho client per broker URL.
connected_count is a shared mutable [int] list; count_lock guards it.
Returns the list of clients (already started with loop_start).
"""
clients = []
for url in broker_urls:
host, port, use_tls = _parse_broker(url)
safe_id = re.sub(r"[^a-zA-Z0-9-]", "-", node_id)[:20]
client = mqtt.Client(
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
client_id=f"its-g5-{safe_id}-{host[:12]}",
protocol=mqtt.MQTTv311,
)
client.will_set(status_topic, payload=b"offline", qos=1, retain=True)
if use_tls:
client.tls_set_context(ssl.create_default_context())
def on_connect(c, userdata, flags, rc, props=None,
_host=host, _port=port, _st=status_topic):
if rc == 0:
logging.info("MQTT connected to %s:%d", _host, _port)
c.publish(_st, b"online", qos=1, retain=True)
with count_lock:
connected_count[0] += 1
dashboard_server.set_mqtt_state(available=connected_count[0] > 0)
dashboard_server.update_broker_status(_host, _port, True)
else:
logging.error("MQTT connect failed %s:%d rc=%s", _host, _port, rc)
dashboard_server.update_broker_status(_host, _port, False)
def on_disconnect(c, userdata, flags, rc, props=None,
_host=host, _port=port):
logging.warning("MQTT disconnected %s:%d rc=%s", _host, _port, rc)
with count_lock:
connected_count[0] = max(0, connected_count[0] - 1)
dashboard_server.set_mqtt_state(available=connected_count[0] > 0)
dashboard_server.update_broker_status(_host, _port, False)
client.on_connect = on_connect
client.on_disconnect = on_disconnect
client.connect_async(host, port, keepalive=60)
client.loop_start()
clients.append(client)
logging.info("MQTT broker configured: %s:%d tls=%s", host, port, use_tls)
return clients
# ---------------------------------------------------------------------------
# Paths — config/ and recordings/ next to the executable
# ---------------------------------------------------------------------------
def _base_dir() -> str:
"""
Windows frozen (.exe) : directory containing the executable
Windows dev : directory containing this script
Linux / macOS : ~/.config/v2x2map (XDG)
"""
if getattr(sys, "frozen", False):
return os.path.dirname(sys.executable)
if sys.platform == "win32":
return os.path.dirname(os.path.abspath(__file__))
xdg = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))
return os.path.join(xdg, "v2x2map")
def _config_path() -> str:
d = _base_dir()
if sys.platform == "win32" or getattr(sys, "frozen", False):
d = os.path.join(d, "config")
os.makedirs(d, exist_ok=True)
return os.path.join(d, "v2x2map.cfg")
def _recordings_dir() -> str:
if getattr(sys, "frozen", False) or sys.platform == "win32":
d = os.path.join(_base_dir(), "recordings")
else:
d = os.path.join(os.path.expanduser("~"), "v2x2map", "recordings")
os.makedirs(d, exist_ok=True)
return d
def _load_config() -> dict:
try:
with open(_config_path(), encoding="utf-8") as f:
return json.load(f)
except Exception:
return {}
def _save_config(cfg: dict) -> None:
try:
with open(_config_path(), "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
except Exception:
pass
def _pause_exit(code: int) -> int:
"""On frozen exe, pause before exit so the user can read error messages."""
if getattr(sys, "frozen", False):
input("\nPress Enter to quit…")
return code
# ---------------------------------------------------------------------------
# Legal disclaimer (shown once on first run, acceptance stored in config)
# ---------------------------------------------------------------------------
_DISCLAIMER = """\
[English]
This software captures IEEE 802.11p / ITS-G5 radio frames in promiscuous mode
and may optionally forward data to an MQTT server.
IMPORTANT:
- Operation is only permitted in environments for which the user holds the
necessary authorisation.
- Intercepting radio signals without permission may violate national law
(e.g. wiretapping statutes, §89 TKG in Germany).
- This software is intended solely for research and development purposes.
Use on public roads or in regulated frequency bands without a regulatory
permit is not allowed.
- Any use is entirely at the user's own risk. The developers accept no
liability for damages or legal violations.
By continuing you confirm that you are aware of and will comply with all
applicable laws.
[Deutsch]
Diese Software erfasst IEEE 802.11p / ITS-G5 Funksignale im Promiscuous-Modus
und kann Daten optional an einen MQTT-Server übertragen.
WICHTIG:
- Der Betrieb ist ausschließlich in Umgebungen gestattet, für die der
Anwender die erforderliche Berechtigung besitzt.
- Das unbefugte Abhören von Funksignalen kann gegen nationales Recht
verstoßen (z. B. § 89 TKG in Deutschland).
- Die Software dient ausschließlich Forschungs- und Entwicklungszwecken.
Eine Nutzung ohne behördliche Genehmigung ist unzulässig.
- Jegliche Nutzung liegt in der alleinigen Verantwortung des Anwenders.
Die Entwickler übernehmen keine Haftung für Schäden oder Verstöße.
Mit der Fortsetzung bestätigen Sie, alle geltenden Gesetze zu kennen
und einzuhalten.\
"""
def _check_disclaimer(cfg: dict) -> bool:
"""Return True if disclaimer is accepted. Shows it on first run."""
if cfg.get("legal_accepted"):
return True
sep = "=" * 66
print(f"\n{sep}")
print(" V2X2MAP ITS-G5 Bridge — Legal Notice / Rechtlicher Hinweis")
print(sep)
print()
print(_DISCLAIMER)
print()
print(sep)
print(" Press ENTER to accept and continue.")
print(" Any other input + ENTER, or Ctrl+C, to quit.")
print(" ENTER druecken zum Akzeptieren, sonstige Eingabe zum Beenden.")
print(sep)
try:
ans = input(" > ").strip()
except (EOFError, KeyboardInterrupt):
ans = None
if ans == "": # bare Enter = accept
cfg["legal_accepted"] = True
_save_config(cfg)
return True
print("\n Not accepted — exiting. / Nicht akzeptiert — Beenden.")
return False
# ---------------------------------------------------------------------------
# main
# ---------------------------------------------------------------------------
def main():
p = argparse.ArgumentParser(
description="ITS-G5 USB->MQTT bridge (V2X2MAP)\n"
f"Config: {_config_path()}",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
p.add_argument("--port", default=None,
help="serial port (e.g. COM7); omit to auto-detect or use config")
p.add_argument("--list-ports", action="store_true",
help="print available serial ports with VID/PID and exit")
p.add_argument("--baud", type=int, default=115200)
p.add_argument("--node-id", default=None,
help="device ID, e.g. V2X2MAP:d2cf13ed6293 (read from config if omitted)")
p.add_argument("--broker", action="append", dest="brokers", default=[],
metavar="URL",
help="MQTT broker URL (mqtts://host:port or mqtt://host:port). "
"Can be specified multiple times. "
f"Default from config or {DEFAULT_BROKER}")
p.add_argument("--no-mqtt", action="store_true",
help="parse only, don't publish (for testing)")
p.add_argument("--reset-on-start", action="store_true",
help="pulse RTS to reboot the device on first connect")
p.add_argument("--reconnect-delay", type=float, default=3.0,
help="seconds to wait before reconnecting after USB disconnect (0 = exit)")
p.add_argument("--exit-after", type=float, default=0,
help="exit after N seconds (0 = run forever)")
p.add_argument("--dashboard-port", type=int, default=8080,
help="local HTTP port for the live dashboard (0 to disable)")
# When launched as frozen exe (double-click), open browser automatically
_frozen = getattr(sys, "frozen", False)
p.add_argument("--open-browser", action="store_true", default=_frozen,
help="open the dashboard URL in the default browser on start "
"(default: on when running as .exe)")
p.add_argument("--no-browser", action="store_true",
help="suppress automatic browser opening")
p.add_argument("--pcap-out", default="",
help="write all captured frames to a .pcap file (DLT_IEEE802_11)")
p.add_argument("--trust", action="append", default=[], metavar="FILE",
help="verify signed packets against this chain: a VCR1 credential bundle "
"or a COER certificate (.oer); repeatable. Default: signatures not checked")
p.add_argument("--verbose", "-v", action="store_true")
args = p.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
if args.list_ports:
for pt in serial.tools.list_ports.comports():
vid_pid = f" [{pt.vid:04X}:{pt.pid:04X}]" if pt.vid else ""
print(f"{pt.device:<12} {pt.description}{vid_pid}")
return 0
# ── Config file + CLI merge (CLI wins) ───────────────────────────────
if args.trust:
global TRUST
from verify import TrustStore
TRUST = TrustStore(args.trust)
cfg = _load_config()
if cfg:
logging.info("Config loaded: %s", _config_path())
# Legal disclaimer — show once, store acceptance in config
if not _check_disclaimer(cfg):
return _pause_exit(0)
node_id = args.node_id or cfg.get("node_id", "")
port = args.port or cfg.get("port")
broker_urls = args.brokers or cfg.get("brokers", [DEFAULT_BROKER])
if not node_id:
print("\n" + "=" * 60)
print(" Error: No node-id configured.")
print(" Run its-g5-setup.exe OR pass")
print(" --node-id V2X2MAP:xxxxxxxxxxxx")
print("=" * 60)
return _pause_exit(1)
if not port:
port = _find_esp_port()
if port:
logging.info("Port auto-detected: %s", port)
else:
print("\n" + "=" * 60)
print(" Error: No serial port found.")
print(" Connect the ESP32 or pass --port.")
print("=" * 60)
return _pause_exit(1)
logging.info("Node-ID : %s", node_id)
logging.info("Port : %s", port)
logging.info("Config : %s", _config_path())
logging.info("Recordings: %s", _recordings_dir())
packet_topic = f"its/{node_id}/packet"
status_topic = f"its/{node_id}/status"
# MQTT publishing starts disabled — user activates from dashboard
dashboard_server.set_mqtt_state(enabled=False, available=False)
connected_count = [0]
count_lock = threading.Lock()
clients: list = []
if not args.no_mqtt:
dashboard_server.register_brokers(broker_urls)
clients = build_mqtt_clients(
node_id, broker_urls, status_topic, connected_count, count_lock
)
# ── Dashboard-controlled PCAP recording ─────────────────────────────────
rec = {"pcap": None, "file": "", "frames": 0}
rec_lock = threading.Lock()
def on_record(action: str, filename: str = "") -> dict:
with rec_lock:
if action == "start":
if rec["pcap"]:
rec["pcap"].close()
fn = filename or os.path.join(
_recordings_dir(), f"its5_{time.strftime('%Y%m%d_%H%M%S')}.pcap"
)
try:
rec["pcap"] = PcapWriter(fn)
rec["file"] = fn
rec["frames"] = 0
st = {"recording": True, "file": fn, "frames": 0}
except Exception as exc:
rec["pcap"] = None
st = {"recording": False, "file": "", "frames": 0, "error": str(exc)}
elif action == "stop":
if rec["pcap"]:
rec["pcap"].close()
rec["pcap"] = None
st = {"recording": False, "file": rec["file"], "frames": rec["frames"]}
else:
st = {"recording": rec["pcap"] is not None,
"file": rec["file"], "frames": rec["frames"]}
dashboard_server.update_record_state(st)
return st
dashboard_server.set_record_callback(on_record)
if args.dashboard_port:
dashboard_server.start(args.dashboard_port, node_id=node_id)
url = f"http://127.0.0.1:{args.dashboard_port}"
logging.info("Dashboard: %s", url)
if args.open_browser and not args.no_browser:
try:
import webbrowser
webbrowser.open(url)
except Exception as exc:
logging.warning("Could not open browser: %s", exc)
# ── Static PCAP output (--pcap-out, always-on) ───────────────────────────
pcap: PcapWriter | None = None
if args.pcap_out:
pcap = PcapWriter(args.pcap_out)
logging.info("PCAP output: %s", args.pcap_out)
n_frames = 0
n_bytes = 0
start_time = time.monotonic()
last_stats = start_time
rate_window: deque = deque()
interval_window: deque = deque(maxlen=200)
last_frame_mono: float | None = None
deadline = time.monotonic() + args.exit_after if args.exit_after > 0 else None
first_connect = True
ser: serial.Serial | None = None
done = False
try:
while not done:
if deadline and time.monotonic() >= deadline:
logging.info("exit-after deadline reached")
break
try:
logging.info("Opening %s @ %d baud", port, args.baud)
ser = _open_serial(port, args.baud)
except serial.SerialException as exc:
logging.warning("Cannot open %s: %s", port, exc)
dashboard_server.broadcast_serial_state(connected=False, port=port)
if args.reconnect_delay <= 0:
break
time.sleep(args.reconnect_delay)
continue
if args.reset_on_start and first_connect:
logging.info("Pulsing RTS to reset device")
ser.rts = True
time.sleep(0.1)
ser.rts = False
time.sleep(0.05)
ser.reset_input_buffer()
first_connect = False
dashboard_server.broadcast_serial_state(connected=True, port=port)
reader = FrameReader()
try:
while not done:
if deadline and time.monotonic() >= deadline:
done = True; break
chunk = ser.read(4096)
if chunk:
n_bytes += len(chunk)
for sec, usec, payload in reader.feed(chunk):
n_frames += 1
now_mono = time.monotonic()
if last_frame_mono is not None:
dt_ms = (now_mono - last_frame_mono) * 1000.0
interval_window.append(dt_ms)
last_frame_mono = now_mono
rate_window.append(now_mono)
while rate_window and rate_window[0] < now_mono - 60.0:
rate_window.popleft()
decoded = decode_itsg5_full(payload)
if args.dashboard_port:
evt = {"n": n_frames, "sec": sec, "usec": usec,
"len": len(payload), "hex": payload[:48].hex()}
evt.update(decoded)
dashboard_server.broadcast_frame(evt)
if clients and dashboard_server.is_mqtt_enabled():
for c in clients:
try:
c.publish(packet_topic, payload, qos=0, retain=False)
except Exception:
pass
if pcap:
pcap.write(sec, usec, payload)
with rec_lock:
if rec["pcap"]:
rec["pcap"].write(sec, usec, payload)
rec["frames"] += 1
if rec["frames"] % 50 == 0:
dashboard_server.update_record_state({
"recording": True,
"file": rec["file"],
"frames": rec["frames"],
})
now_mono = time.monotonic()
if now_mono - last_stats >= 5.0:
rate = len(rate_window)
uptime = int(now_mono - start_time)
# Performance statistics (frequency, jitter, min/max inter-arrival interval)
freq_hz = round(rate / 60.0, 2)
mean_interval_ms = 0.0
jitter_ms = 0.0
min_interval_ms = 0.0
max_interval_ms = 0.0
if interval_window:
mean_interval_ms = round(sum(interval_window) / len(interval_window), 2)
var = sum((x - mean_interval_ms) ** 2 for x in interval_window) / len(interval_window)
jitter_ms = round(math.sqrt(var), 2)
min_interval_ms = round(min(interval_window), 2)
max_interval_ms = round(max(interval_window), 2)
if mean_interval_ms > 0:
freq_hz = round(1000.0 / mean_interval_ms, 2)
logging.info("frames=%d rate=%d/min (%.2f Hz) mean_dt=%.1fms (jitter=%.2fms) bytes=%d uptime=%ds",
n_frames, rate, freq_hz, mean_interval_ms, jitter_ms, n_bytes, uptime)
if args.dashboard_port:
dashboard_server.broadcast_stats({
"frames": n_frames,
"rate": rate,
"freq_hz": freq_hz,
"mean_interval_ms": mean_interval_ms,
"jitter_ms": jitter_ms,
"min_interval_ms": min_interval_ms,
"max_interval_ms": max_interval_ms,
"bytes": n_bytes,
"uptime_s": uptime,
})
last_stats = now_mono
except serial.SerialException as exc:
logging.warning("Serial error: %s", exc)
dashboard_server.broadcast_serial_state(connected=False, port=port)
try: ser.close()
except Exception: pass
ser = None
if args.reconnect_delay <= 0:
break
logging.info("Reconnecting in %.1f s…", args.reconnect_delay) # noqa: RUF001
time.sleep(args.reconnect_delay)
except KeyboardInterrupt:
logging.info("Stopping (frames=%d)", n_frames)
finally:
for c in clients:
try:
c.publish(status_topic, b"offline", qos=1, retain=True)
c.loop_stop()
c.disconnect()
except Exception:
pass
if ser is not None:
try: ser.close()
except Exception: pass
if pcap:
pcap.close()
with rec_lock:
if rec["pcap"]:
rec["pcap"].close()
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,111 @@
"""
Generates ICO files for its-g5-bridge.exe and its-g5-setup.exe.
Run before building: python make_icons.py
Requires Pillow: pip install pillow
"""
import math
import os
from PIL import Image, ImageDraw
# ---------------------------------------------------------------------------
# Bridge icon — dark blue circle with WiFi/antenna waves
# ---------------------------------------------------------------------------
def make_bridge_icon(size: int = 256) -> Image.Image:
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
# Outer circle
p = int(size * 0.04)
d.ellipse([p, p, size - p, size - p], fill=(21, 101, 192, 255))
cx = size / 2
cy = size * 0.60 # arc origin slightly below centre
lw = max(4, int(size * 0.055))
# Three concentric WiFi arcs (opening downward)
for i, r_frac in enumerate([0.16, 0.28, 0.40]):
rp = r_frac * size
alpha = max(120, 255 - i * 40)
d.arc([cx - rp, cy - rp, cx + rp, cy + rp],
start=205, end=335,
fill=(255, 255, 255, alpha), width=lw)
# Centre dot
dr = int(size * 0.048)
d.ellipse([cx - dr, cy - dr, cx + dr, cy + dr],
fill=(255, 255, 255, 255))
# Mast line from dot upward
mw = max(3, int(size * 0.03))
d.rectangle([cx - mw // 2, cy - size * 0.36,
cx + mw // 2, cy - dr],
fill=(255, 255, 255, 230))
return img
# ---------------------------------------------------------------------------
# Setup icon — slate circle with amber gear
# ---------------------------------------------------------------------------
def make_setup_icon(size: int = 256) -> Image.Image:
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
# Outer circle
p = int(size * 0.04)
d.ellipse([p, p, size - p, size - p], fill=(55, 71, 79, 255))
cx, cy = size / 2, size / 2
teeth = 8
r_out = size * 0.34
r_in = size * 0.25
t_half = 0.18 # half angular width of tooth
pts = []
for i in range(teeth):
base = i * 2 * math.pi / teeth
mid = base + math.pi / teeth
# leading edge of tooth
pts += [(cx + r_out * math.cos(base - t_half),
cy + r_out * math.sin(base - t_half)),
(cx + r_out * math.cos(base + t_half),
cy + r_out * math.sin(base + t_half))]
# valley between teeth
pts += [(cx + r_in * math.cos(mid - t_half),
cy + r_in * math.sin(mid - t_half)),
(cx + r_in * math.cos(mid + t_half),
cy + r_in * math.sin(mid + t_half))]
d.polygon(pts, fill=(255, 193, 7, 255)) # amber gear
# Inner hole
hr = int(size * 0.13)
d.ellipse([cx - hr, cy - hr, cx + hr, cy + hr],
fill=(55, 71, 79, 255))
# Small centre highlight
hl = int(size * 0.055)
d.ellipse([cx - hl, cy - hl, cx + hl, cy + hl],
fill=(255, 224, 102, 255))
return img
# ---------------------------------------------------------------------------
# Save as multi-size ICO
# ---------------------------------------------------------------------------
SIZES = [(16, 16), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)]
def save_ico(img: Image.Image, path: str) -> None:
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
img.save(path, format="ICO", sizes=SIZES)
kb = os.path.getsize(path) // 1024
print(f" {path} ({kb} KB)")
if __name__ == "__main__":
print("Generating icons ...")
save_ico(make_bridge_icon(256), "icons/its-g5-bridge.ico")
save_ico(make_setup_icon(256), "icons/its-g5-setup.ico")
print("Done.")
@@ -0,0 +1,3 @@
asn1tools==0.167.0
paho-mqtt>=2.0,<3
pyserial>=3.5,<4
@@ -0,0 +1,339 @@
"""Locate the payload of a signed GeoNetworking packet (ETSI TS 103 097 / IEEE Std 1609.2).
A GeoNetworking basic header with Next Header = 2 is followed by an `Ieee1609Dot2Data`
(COER, TS 103 097 clause 5.2 `EtsiTs103097Data-Signed`): the GN common header, extended
header, BTP header and facilities PDU are the `unsecuredData` inside
`signedData.tbsData.payload.data`. This walks exactly that path with the OER rules for the
fields in front of it and returns the payload with the header information (PSID,
generation time, signer). It does NOT verify the signature or the certificate: a decoded
VAM says what the sender claims, not that the claim was authenticated. Nothing is guessed:
any deviation from the grammar raises ValueError and the caller leaves the packet undecoded.
"""
from __future__ import annotations
import hashlib
ITS_EPOCH_UNIX = 1072915200
LEAP_SECONDS_SINCE_2004 = 5 # TAI-UTC 37 s since 2017, 32 s at the ITS epoch
class _Reader:
def __init__(self, data: bytes, at: int = 0):
self.data = data
self.at = at
def take(self, n: int) -> bytes:
if n < 0 or self.at + n > len(self.data):
raise ValueError("truncated security envelope")
chunk = self.data[self.at:self.at + n]
self.at += n
return chunk
def u8(self) -> int:
return self.take(1)[0]
def length(self) -> int:
"""OER length determinant: one octet below 128, else 0x80|n followed by n octets."""
first = self.u8()
if first < 0x80:
return first
n = first & 0x7F
if n == 0 or n > 4:
raise ValueError("unsupported length determinant")
return int.from_bytes(self.take(n), "big")
def unsigned(self) -> int:
"""Unconstrained non-negative INTEGER (Psid, quantities): length determinant then the magnitude."""
n = self.length()
if n == 0 or n > 8:
raise ValueError("bad integer length")
return int.from_bytes(self.take(n), "big")
def open_type(self) -> bytes:
"""An OER open type (extension addition, CHOICE extension alternative): length-prefixed."""
return self.take(self.length())
# ---- skipping helpers for the certificate carried inline by a signer ---------------------
def _skip_octets(r: _Reader) -> None:
r.take(r.length())
def _skip_sequence_of(r: _Reader, element) -> None:
count = r.unsigned() # OER quantity field
if count > 4096:
raise ValueError("implausible SEQUENCE OF size")
for _ in range(count):
element(r)
def _skip_extension_addition(r: _Reader) -> None:
"""OER extension part: presence bitmap (length-prefixed BIT STRING) then one open type per set bit."""
bitmap = r.take(r.length())
count = sum(bin(octet).count("1") for octet in bitmap[1:]) # bitmap[0] is the unused-bit count
for _ in range(count):
r.open_type()
def _skip_ecc_point(r: _Reader, size: int) -> None:
tag = r.u8()
if tag in (0x80, 0x82, 0x83): # x-only, compressed-y-0, compressed-y-1
r.take(size)
elif tag == 0x84: # uncompressed
r.take(2 * size)
else:
raise ValueError("unknown EccCurvePoint")
def _skip_public_verification_key(r: _Reader) -> None:
tag = r.u8()
if tag in (0x80, 0x81): # ecdsaNistP256, ecdsaBrainpoolP256r1 (root alternatives)
_skip_ecc_point(r, 32)
elif tag in (0x82, 0x83): # ecdsaBrainpoolP384r1, ecdsaNistP384: extension alternatives, open type
r.open_type()
else:
raise ValueError("unknown PublicVerificationKey")
def _skip_public_encryption_key(r: _Reader) -> None:
r.u8() # supportedSymmAlg ENUMERATED
tag = r.u8() # BasePublicEncryptionKey CHOICE
if tag in (0x80, 0x81):
_skip_ecc_point(r, 32)
else:
raise ValueError("unknown BasePublicEncryptionKey")
def _skip_signature(r: _Reader) -> None:
tag = r.u8()
if tag in (0x80, 0x81): # ecdsaNistP256Signature, ecdsaBrainpoolP256r1Signature: rSig point, sSig 32
_skip_ecc_point(r, 32)
r.take(32)
elif tag in (0x82, 0x83): # brainpoolP384r1 / nistP384: extension alternatives, open type
r.open_type()
else:
raise ValueError("unknown Signature")
def _skip_ssp(r: _Reader) -> None:
tag = r.u8()
if tag in (0x80, 0x81): # opaque / bitmapSsp: OCTET STRING
_skip_octets(r)
else:
raise ValueError("unknown ServiceSpecificPermissions")
def _skip_psid_ssp(r: _Reader) -> None:
pre = r.u8() # bit 7: ssp present
r.unsigned() # psid
if pre & 0x80:
_skip_ssp(r)
def _skip_psid_ssp_range(r: _Reader) -> None:
pre = r.u8()
r.unsigned()
if pre & 0x80:
tag = r.u8()
if tag == 0x80: # opaque: SEQUENCE OF OCTET STRING
_skip_sequence_of(r, _skip_octets)
elif tag == 0x81: # all NULL
pass
elif tag == 0x82: # bitmapSspRange: sspValue, sspBitmask
_skip_octets(r)
_skip_octets(r)
else:
raise ValueError("unknown SspRange")
def _skip_psid_group_permissions(r: _Reader) -> None:
pre = r.u8() # bit 7 minChainLength, bit 6 chainLengthRange, bit 5 eeType
tag = r.u8() # SubjectPermissions CHOICE
if tag == 0x80: # explicit: SEQUENCE OF PsidSspRange
_skip_sequence_of(r, _skip_psid_ssp_range)
elif tag == 0x81: # all NULL
pass
else:
raise ValueError("unknown SubjectPermissions")
if pre & 0x80:
r.unsigned() # minChainLength INTEGER DEFAULT 1
if pre & 0x40:
r.unsigned() # chainLengthRange INTEGER DEFAULT 0
if pre & 0x20:
r.take(1) # EndEntityType BIT STRING (SIZE 8)
def _skip_identified_region(r: _Reader) -> None:
tag = r.u8()
if tag == 0x80: # countryOnly Uint16
r.take(2)
elif tag == 0x81: # countryAndRegions: country, SEQUENCE OF Uint8
r.take(2)
_skip_sequence_of(r, lambda rr: rr.take(1))
elif tag == 0x82: # countryAndSubregions: country, SEQUENCE OF RegionAndSubregions
r.take(2)
_skip_sequence_of(r, lambda rr: (rr.take(1), _skip_sequence_of(rr, lambda x: x.take(2))))
else:
raise ValueError("unknown IdentifiedRegion")
def _skip_geographic_region(r: _Reader) -> None:
tag = r.u8()
if tag == 0x80: # circularRegion: TwoDLocation 8 + radius 2
r.take(10)
elif tag == 0x81: # rectangularRegion: SEQUENCE OF RectangularRegion (2 x TwoDLocation)
_skip_sequence_of(r, lambda rr: rr.take(16))
elif tag == 0x82: # polygonalRegion: SEQUENCE OF TwoDLocation
_skip_sequence_of(r, lambda rr: rr.take(8))
elif tag == 0x83: # identifiedRegion
_skip_sequence_of(r, _skip_identified_region)
else:
raise ValueError("unknown GeographicRegion")
def _skip_to_be_signed_certificate(r: _Reader) -> None:
# One preamble octet: bit 7 extension, then region, assuranceLevel, appPermissions,
# certIssuePermissions, certRequestPermissions, canRequestRollover, encryptionKey (bits 6..0).
pre = r.u8()
cid = r.u8() # CertificateId CHOICE
if cid == 0x80:
raise ValueError("linkageData CertificateId unsupported")
elif cid in (0x81, 0x82): # name Hostname / binaryId OCTET STRING
_skip_octets(r)
elif cid != 0x83: # none NULL
raise ValueError("unknown CertificateId")
r.take(3) # cracaId HashedId3
r.take(2) # crlSeries Uint16
r.take(4) # validityPeriod.start Time32
r.u8() # Duration CHOICE tag
r.take(2) # every Duration alternative is a Uint16
if pre & 0x40:
_skip_geographic_region(r)
if pre & 0x20:
r.take(1) # SubjectAssurance OCTET STRING (SIZE 1)
if pre & 0x10:
_skip_sequence_of(r, _skip_psid_ssp)
if pre & 0x08:
_skip_sequence_of(r, _skip_psid_group_permissions)
if pre & 0x04:
_skip_sequence_of(r, _skip_psid_group_permissions)
# bit 0x02 canRequestRollover is NULL: nothing to skip
if pre & 0x01:
_skip_public_encryption_key(r)
vki = r.u8() # VerificationKeyIndicator CHOICE
if vki == 0x80:
_skip_public_verification_key(r)
elif vki == 0x81:
_skip_ecc_point(r, 32) # reconstructionValue EccP256CurvePoint
else:
raise ValueError("unknown VerificationKeyIndicator")
if pre & 0x80:
_skip_extension_addition(r)
def _skip_certificate(r: _Reader) -> bytes:
"""Consume one COER `EtsiTs103097Certificate` and return its octets (for the HashedId8)."""
start = r.at
pre = r.u8() # bit 7: signature present
if r.u8() != 3:
raise ValueError("certificate version is not 3")
r.u8() # CertificateType ENUMERATED explicit / implicit
issuer = r.u8() # IssuerIdentifier CHOICE
if issuer in (0x80, 0x82): # sha256AndDigest / sha384AndDigest: HashedId8
r.take(8)
elif issuer == 0x81: # self: HashAlgorithm
r.take(1)
else:
raise ValueError("unknown IssuerIdentifier")
_skip_to_be_signed_certificate(r)
if pre & 0x80:
_skip_signature(r)
return r.data[start:r.at]
def hashed_id8(certificate: bytes) -> str:
return hashlib.sha256(certificate).digest()[-8:].hex()
# ---- the entry point ---------------------------------------------------------------------
def unwrap_signed(envelope: bytes) -> dict:
"""Parse an `Ieee1609Dot2Data` and return the signed payload with its header info.
Returns {"payload": bytes, "psid": int, "generation_time_ms": int | None, "hash_id": str,
"signer": "digest" | "certificate" | "self" | "unknown", "signer_id": hex str | None}.
Raises ValueError for anything but a signedData whose payload carries data.
"""
r = _Reader(envelope)
if r.u8() != 3:
raise ValueError("Ieee1609Dot2Data protocolVersion is not 3")
content = r.u8()
if content == 0x80:
raise ValueError("unsecuredData at the top level is not a signed packet")
if content != 0x81:
raise ValueError("not signedData (encrypted or unknown content)")
hash_id = {0: "sha256", 1: "sha384", 2: "sm3"}.get(r.u8())
if hash_id is None:
raise ValueError("unknown HashAlgorithm")
# ToBeSignedData ::= SEQUENCE { payload SignedDataPayload, headerInfo HeaderInfo }
payload_pre = r.u8() # SignedDataPayload: bit 7 extension, bit 6 data, bit 5 extDataHash
if not payload_pre & 0x40:
raise ValueError("signed data without a payload (extDataHash only)")
if r.u8() != 3:
raise ValueError("inner Ieee1609Dot2Data protocolVersion is not 3")
if r.u8() != 0x80:
raise ValueError("inner content is not unsecuredData")
payload = r.take(r.length())
if payload_pre & 0x20:
if r.u8() != 0x80:
raise ValueError("unknown HashedData")
r.take(32)
if payload_pre & 0x80:
_skip_extension_addition(r)
# HeaderInfo: bit 7 extension; bits 6..1 generationTime, expiryTime, generationLocation,
# p2pcdLearningRequest, missingCrlIdentifier, encryptionKey
hi = r.u8()
psid = r.unsigned()
generation_time = int.from_bytes(r.take(8), "big") if hi & 0x40 else None
result = {"payload": payload, "psid": psid, "hash_id": hash_id,
"generation_time_ms": None if generation_time is None else generation_time // 1000,
"signer": "unknown", "signer_id": None}
# The signer and the signature follow the remaining HeaderInfo fields. They are walked to the
# end so that a truncated or malformed envelope is refused as a whole: a payload from a message
# whose signature is not even structurally present is not shown as a VAM.
if True:
if hi & 0x20:
r.take(8) # expiryTime Time64
if hi & 0x10:
r.take(10) # generationLocation ThreeDLocation: lat 4, lon 4, elevation 2
if hi & 0x08:
r.take(3) # p2pcdLearningRequest HashedId3
if hi & 0x04:
mc = r.u8() # MissingCrlIdentifier (extensible): cracaId 3, crlSeries 2
r.take(5)
if mc & 0x80:
_skip_extension_addition(r)
if hi & 0x02:
_skip_public_encryption_key(r)
if hi & 0x80:
_skip_extension_addition(r)
signer_tag = r.u8() # SignerIdentifier CHOICE
if signer_tag == 0x80:
result.update(signer="digest", signer_id=r.take(8).hex())
elif signer_tag == 0x81:
certificates: list = []
_skip_sequence_of(r, lambda rr: certificates.append(_skip_certificate(rr)))
result.update(signer="certificate", signer_id=hashed_id8(certificates[0]) if certificates else None)
elif signer_tag == 0x82:
result.update(signer="self")
else:
raise ValueError("unknown SignerIdentifier")
_skip_signature(r)
result["consumed"] = r.at
return result
def generation_time_utc(generation_time_ms: int) -> float:
"""IEEE 1609.2 Time64 (TAI since the ITS epoch, here in ms) -> Unix seconds (UTC)."""
return generation_time_ms / 1000.0 + ITS_EPOCH_UNIX - LEAP_SECONDS_SINCE_2004
@@ -0,0 +1,51 @@
"""
Entry point for the V2X2MAP Setup executable (its-g5-setup.exe).
Runs the setup wizard; if the user checks "Bridge automatisch starten",
launches its-g5-bridge.exe from the same directory afterwards.
"""
import os
import subprocess
import sys
def main():
from setup_wizard import run_wizard
result = run_wizard()
if result is None:
sys.exit(0)
if result.get("auto_start"):
exe_dir = os.path.dirname(
sys.executable if getattr(sys, "frozen", False)
else os.path.abspath(__file__)
)
bridge = os.path.join(exe_dir, "its-g5-bridge.exe")
if not os.path.isfile(bridge):
bridge = os.path.join(exe_dir, "its-g5-bridge")
# Pass all settings explicitly — config file may not be readable yet
# on the very first launch (race condition / path issues).
cmd = [bridge, "--open-browser",
"--node-id", result["node_id"]]
if result.get("port"):
cmd += ["--port", result["port"]]
for url in result.get("brokers", []):
cmd += ["--broker", url]
try:
subprocess.Popen(cmd)
except Exception as exc:
# tkinter root is already destroyed — use Windows MessageBox directly
import ctypes
ctypes.windll.user32.MessageBoxW(
0,
f"Could not launch its-g5-bridge.exe:\n{exc}\n\n"
f"Make sure both EXE files are in the same folder:\n{exe_dir}",
"V2X2MAP — Launch failed",
0x30, # MB_ICONWARNING | MB_OK
)
if __name__ == "__main__":
main()
@@ -0,0 +1,454 @@
"""
Tkinter setup wizard for the V2X2MAP ITS-G5 Receiver bridge.
Steps:
1. Device — pick the serial port
2. Flash — write the bundled firmware
3. Done — the device derives its node-id from its own MAC automatically
Returns dict {port} to the caller.
"""
from __future__ import annotations
import io
import os
import queue
import re
import sys
import threading
import tkinter as tk
from tkinter import messagebox, scrolledtext, ttk
import serial
import serial.tools.list_ports
FIRMWARE_FILES: list[tuple[str, str]] = [
("0x2000", "bootloader.bin"),
("0x8000", "partition-table.bin"),
("0x1e000", "ota_data_initial.bin"),
("0x20000", "firmware.bin"),
]
FLASH_BAUD = "921600"
CHIP = "esp32c5"
# ── platform styling ────────────────────────────────────────────────────────
IS_WINDOWS = sys.platform.startswith("win")
IS_MAC = sys.platform == "darwin"
PORT_WORD = "COM port" if IS_WINDOWS else "Serial port"
UI_FONT = "Segoe UI" if IS_WINDOWS else ("Helvetica Neue" if IS_MAC else "DejaVu Sans")
MONO_FONT = "Consolas" if IS_WINDOWS else ("Menlo" if IS_MAC else "DejaVu Sans Mono")
# USB vendor IDs that indicate an ESP32 devboard (native USB-JTAG or common UART bridge).
_ESP_VIDS = {0x303A} # Espressif USB-JTAG/serial
_UART_VIDS = {0x10C4, 0x1A86, 0x0403} # CP210x, CH34x, FTDI
# Ports that are never a devboard (macOS internals).
_PORT_BLACKLIST = ("bluetooth-incoming", "debug-console", "wlan-debug")
def _resource_path(*parts: str) -> str:
base = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)))
return os.path.join(base, *parts)
_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07")
class _UiWriter(io.TextIOBase):
"""File-like sink that forwards text to a queue drained by the Tk MAIN thread.
Two hard-won rules live here (E2E test on macOS/Tk 9 found both):
- NEVER touch tkinter from a worker thread — even `root.after` silently drops
events on some builds. Workers only enqueue; `SetupWizard._pump` renders.
- esptool ≥5 probes its stdout like a real file (isatty & friends) and emits
ANSI cursor codes; TextIOBase supplies the full API and we strip the codes.
"""
def __init__(self, q: "queue.Queue", widget: tk.Text):
super().__init__()
self._q = q; self._widget = widget
def writable(self) -> bool:
return True
def isatty(self) -> bool:
return False
@property
def encoding(self) -> str: # some libs read .encoding directly
return "utf-8"
def write(self, s: str) -> int:
clean = _ANSI_RE.sub("", s)
if clean:
self._q.put(("log", self._widget, clean))
return len(s)
def flush(self) -> None:
pass
class _MarkerArt:
"""Draws the app's map-marker artwork (top-down car, bus, 3-light signal) onto a
tk.Canvas — the same visual language as the V2X2MAP app, as pure vector calls so
the bundle needs no image assets and stays crisp at any scale."""
VEHICLE_BLUE = "#2f7fd0"
BODY_DARK = "#242a30"
@staticmethod
def _rounded(c: tk.Canvas, x0, y0, x1, y1, r, **kw):
"""Rounded rectangle as a smoothed polygon."""
pts = [x0+r,y0, x1-r,y0, x1,y0, x1,y0+r, x1,y1-r, x1,y1, x1-r,y1,
x0+r,y1, x0,y1, x0,y1-r, x0,y0+r, x0,y0]
return c.create_polygon(pts, smooth=True, **kw)
@classmethod
def car(cls, c: tk.Canvas, cx, cy, s=1.0):
w, h = 22*s, 38*s
x0, y0 = cx-w/2, cy-h/2
# mirrors
for mx in (x0-2.5*s, x0+w-0.5*s):
cls._rounded(c, mx, y0+h*0.30, mx+3*s, y0+h*0.30+6*s, 1.2*s,
fill=cls.VEHICLE_BLUE, outline="white", width=1)
cls._rounded(c, x0, y0, x0+w, y0+h, w*0.32, fill=cls.VEHICLE_BLUE,
outline="white", width=1.5*s)
cls._rounded(c, x0+3*s, y0+h*0.17, x0+w-3*s, y0+h*0.32, 2*s,
fill="#1b4d80", outline="")
cls._rounded(c, x0+3*s, y0+h*0.66, x0+w-3*s, y0+h*0.79, 2*s,
fill="#245e9b", outline="")
@classmethod
def bus(cls, c: tk.Canvas, cx, cy, s=1.0):
w, h = 23*s, 48*s
x0, y0 = cx-w/2, cy-h/2
cls._rounded(c, x0, y0, x0+w, y0+h, 6*s, fill=cls.VEHICLE_BLUE,
outline="white", width=1.5*s)
cls._rounded(c, x0+3*s, y0+h*0.10, x0+w-3*s, y0+h*0.22, 1.6*s,
fill="#1b4d80", outline="")
for f in (0.34, 0.50, 0.66):
cls._rounded(c, x0+4*s, y0+h*f, x0+w-4*s, y0+h*f+2.6*s, 1.2*s,
fill="#7db4e8", outline="")
@classmethod
def signal(cls, c: tk.Canvas, cx, cy, s=1.0, active="green"):
r = 17*s
c.create_oval(cx-r, cy-r, cx+r, cy+r, fill="#2b3138", outline="white", width=1.5*s)
cls._rounded(c, cx-6.5*s, cy-13*s, cx+6.5*s, cy+13*s, 5*s,
fill="#161a1e", outline="#8a949e", width=1)
lamps = {"red": "#ff5555", "amber": "#ffc837", "green": "#5bdd6e"}
for i, (name, col) in enumerate(lamps.items()):
ly = cy + (i-1)*8*s
fill = col if name == active else "#3c444d"
c.create_oval(cx-3.6*s, ly-3.6*s, cx+3.6*s, ly+3.6*s, fill=fill, outline="")
class SetupWizard:
def __init__(self) -> None:
self.result: dict | None = None
self.root = tk.Tk()
self.root.title("V2X2MAP — Setup")
self.root.geometry("640x560")
self.root.minsize(620, 500)
self._center_window()
self.port_var = tk.StringVar()
self.skip_flash = tk.BooleanVar(value=False)
self._port_label_to_device: dict[str, str] = {}
self._busy = False
# Single UI queue: worker threads enqueue log text and callables; only the
# main thread (via _pump) touches tkinter.
self._uiq: queue.Queue = queue.Queue()
self._build()
self._pump()
def _pump(self) -> None:
try:
while True:
item = self._uiq.get_nowait()
if item[0] == "log":
_, widget, chunk = item
widget.insert("end", chunk)
widget.see("end")
else:
_, fn, args = item
fn(*args)
except queue.Empty:
pass
self.root.after(80, self._pump)
def _post(self, fn, *args) -> None:
"""Thread-safe: schedule `fn(*args)` on the main thread."""
self._uiq.put(("call", fn, args))
# ── UI ──────────────────────────────────────────────────────────────────
def _center_window(self) -> None:
self.root.update_idletasks()
w, h = 640, 560
x = (self.root.winfo_screenwidth() - w) // 2
y = max(40, (self.root.winfo_screenheight() - h) // 3)
self.root.geometry(f"{w}x{h}+{x}+{y}")
def _build_header(self) -> None:
"""Brand banner: dark map-night ground, app title, and the app's own map
markers (car, bus, live traffic signal) drawn as vector art."""
c = tk.Canvas(self.root, height=76, highlightthickness=0, bg="#1d2530")
c.pack(fill="x")
c.create_text(20, 28, anchor="w", text="V2X2MAP",
font=(UI_FONT, 20, "bold"), fill="white")
c.create_text(20, 52, anchor="w", text="ITS-G5 Receiver · Setup & Firmware-Flasher",
font=(UI_FONT, 11), fill="#93a1af")
def place_markers(_evt=None):
c.delete("art")
before = set(c.find_all())
w = c.winfo_width() or 640
# subtle "road" line behind the vehicles
c.create_line(w-215, 66, w-15, 10, fill="#39424c", width=10, capstyle="round")
c.create_line(w-215, 66, w-15, 10, fill="#4a5560", width=1, dash=(4, 6))
_MarkerArt.car(c, w-180, 46, 0.82)
_MarkerArt.bus(c, w-120, 38, 0.82)
_MarkerArt.signal(c, w-52, 34, 0.95, active="green")
for item in set(c.find_all()) - before:
c.addtag_withtag("art", item)
c.bind("<Configure>", place_markers)
place_markers()
def _build(self) -> None:
self._build_header()
nb = ttk.Notebook(self.root)
nb.pack(fill="both", expand=True, padx=10, pady=10)
self.nb = nb
self._build_step1(nb)
self._build_step2(nb)
self._build_done(nb)
# ── Step 1: Device ───────────────────────────────────────────────────────
def _build_step1(self, nb):
f = ttk.Frame(nb, padding=20); nb.add(f, text="1. Device")
ttk.Label(f, text="V2X2MAP Setup", font=(UI_FONT, 15, "bold")).pack(anchor="w")
ttk.Label(f, text=f"Connect the ESP32-C5 via USB and select the {PORT_WORD}.\n"
"Detected ESP32 boards are preselected automatically.",
wraplength=540, justify="left").pack(anchor="w", pady=(8, 16))
bar = ttk.Frame(f); bar.pack(side="bottom", fill="x", pady=(20, 0))
ttk.Button(bar, text="Cancel", command=self._cancel).pack(side="right")
ttk.Button(bar, text="Next >", command=self._goto_step2).pack(side="right", padx=(0, 8))
row = ttk.Frame(f); row.pack(fill="x")
ttk.Label(row, text=f"{PORT_WORD}:").pack(side="left")
self.port_combo = ttk.Combobox(row, textvariable=self.port_var, state="readonly")
self.port_combo.pack(side="left", padx=(8, 8), fill="x", expand=True)
ttk.Button(row, text="Refresh", command=self._refresh_ports).pack(side="left")
ttk.Checkbutton(f, text="Skip firmware flash (already flashed)",
variable=self.skip_flash).pack(anchor="w", pady=(20, 0))
self._refresh_ports()
# ── Step 2: Flash ────────────────────────────────────────────────────────
def _build_step2(self, nb):
f = ttk.Frame(nb, padding=20); nb.add(f, text="2. Flash")
# Button bar packed FIRST: pack() starves last-packed widgets when space runs
# out, and the bar must always win over the expanding log.
bar = ttk.Frame(f); bar.pack(side="bottom", fill="x", pady=(12, 0))
self.flash_btn = ttk.Button(bar, text="Start flash", command=self._start_flash)
self.flash_btn.pack(side="right", padx=(0, 8))
self.flash_next_btn = ttk.Button(bar, text="Next >",
command=lambda: self.nb.select(2), state="disabled")
self.flash_next_btn.pack(side="right", padx=(0, 8))
ttk.Button(bar, text="< Back", command=lambda: self.nb.select(0)).pack(side="right")
ttk.Label(f, text="Flash firmware", font=(UI_FONT, 13, "bold")).pack(anchor="w")
ttk.Label(f, text="Writes bootloader, partition table and application. Takes 30-60 s.",
wraplength=540, justify="left").pack(anchor="w", pady=(4, 12))
self.flash_status = ttk.Label(f, text="Ready.")
self.flash_status.pack(anchor="w")
self.flash_progress = ttk.Progressbar(f, mode="determinate")
self.flash_progress.pack(fill="x", pady=(0, 8))
# Boot-mode help: quietly present from the start, highlighted when a flash
# attempt fails to connect (the one situation where it's actually needed).
self.bootmode_box = tk.Frame(f, bd=1, relief="solid",
highlightthickness=0, bg="#f4f6f8")
self.bootmode_label = tk.Label(
self.bootmode_box, justify="left", anchor="w", bg="#f4f6f8", fg="#3a4552",
font=(UI_FONT, 11),
text=("If the device is not detected: put the ESP32-C5 into boot mode —\n"
" 1. hold the BOOT button\n"
" 2. tap RESET once (or re-plug USB) while still holding BOOT\n"
" 3. release BOOT, then press “Start flash” again"))
self.bootmode_label.pack(fill="x", padx=10, pady=8)
self.bootmode_box.pack(fill="x", pady=(0, 8))
self.flash_log = scrolledtext.ScrolledText(f, height=12, font=(MONO_FONT, 10), wrap="none")
self.flash_log.pack(fill="both", expand=True)
# ── Step 3: Done ─────────────────────────────────────────────────────────
def _build_done(self, nb):
f = ttk.Frame(nb, padding=20); nb.add(f, text="3. Done")
bar = ttk.Frame(f); bar.pack(side="bottom", fill="x", pady=(12, 0))
ttk.Button(bar, text="Finish", command=self._finish).pack(side="right", padx=(0, 8))
ttk.Button(bar, text="< Back", command=lambda: self.nb.select(1)).pack(side="right")
box = tk.Canvas(f, height=88, highlightthickness=0, bg="#eef6ee")
box.pack(fill="x", pady=(4, 12))
box.create_oval(18, 20, 66, 68, fill="#2e7d32", outline="")
box.create_line(30, 45, 40, 55, 56, 32, fill="white", width=5,
capstyle="round", joinstyle="round")
box.create_text(84, 34, anchor="w", text="Receiver ready!",
font=(UI_FONT, 15, "bold"), fill="#20301f")
box.create_text(84, 58, anchor="w",
text="Firmware flashed — the device restarts by itself.",
font=(UI_FONT, 11), fill="#4a5548")
ttk.Label(f, justify="left", wraplength=560, text=(
"No further configuration is needed:\n\n"
" • The receiver derives its node-id automatically from its own\n"
" hardware MAC address (V2X2MAP…).\n"
" • Open the V2X2MAP app on your phone — the receiver appears\n"
" via Bluetooth within a few seconds.")).pack(anchor="w")
# ── serial port ──────────────────────────────────────────────────────────
@staticmethod
def _port_score(p) -> int:
"""Higher = more likely the ESP32 devboard. Blacklisted internals sink."""
dev = p.device.lower()
if any(b in dev for b in _PORT_BLACKLIST):
return -1
vid = getattr(p, "vid", None)
if vid in _ESP_VIDS:
return 100 # Espressif USB-JTAG: that's the board
if vid in _UART_VIDS:
return 60 # common devboard UART bridge
if "usbmodem" in dev or "usbserial" in dev or "ttyusb" in dev or "ttyacm" in dev:
return 30 # generic USB serial
return 0
def _refresh_ports(self):
ports = sorted(serial.tools.list_ports.comports(),
key=self._port_score, reverse=True)
labels: list[str] = []
self._port_label_to_device.clear()
for p in ports:
if self._port_score(p) < 0:
continue # hide macOS-internal pseudo ports
desc = (p.description or "").strip()
label = p.device if desc in ("", "n/a") else f"{p.device} - {desc}"
if getattr(p, "vid", None) in _ESP_VIDS:
label += " ← ESP32"
labels.append(label)
self._port_label_to_device[label] = p.device
self.port_combo["values"] = labels
if labels:
if not self.port_var.get() or self.port_var.get() not in self._port_label_to_device:
self.port_var.set(labels[0]) # best-scored port preselected
else:
self.port_var.set("")
def _selected_port(self) -> str:
return self._port_label_to_device.get(self.port_var.get(), "")
# ── Navigation ───────────────────────────────────────────────────────────
def _goto_step2(self):
if not self._selected_port():
messagebox.showerror("Error", f"Please select a {PORT_WORD} first.")
return
self.nb.select(2 if self.skip_flash.get() else 1)
def _cancel(self):
if self._busy: return
self.result = None
self.root.destroy()
# ── Flash ─────────────────────────────────────────────────────────────────
def _start_flash(self):
if self._busy: return
port = self._selected_port()
if not port:
messagebox.showerror("Error", f"No {PORT_WORD} selected."); return
for _, name in FIRMWARE_FILES:
path = _resource_path("firmware", name)
if not os.path.isfile(path):
messagebox.showerror("Error", f"Firmware file missing: {name}"); return
self._busy = True
self.flash_btn.configure(state="disabled")
self.flash_next_btn.configure(state="disabled")
self.flash_status.configure(text=f"Flashing {port} ...")
self.flash_log.delete("1.0", "end")
self.flash_progress.configure(mode="indeterminate")
self.flash_progress.start(60)
threading.Thread(target=self._flash_worker, args=(port,), daemon=True).start()
def _flash_worker(self, port):
# esptool v4 spells options with underscores, v5 with dashes — pick per version.
try:
import esptool
v5 = int(str(getattr(esptool, "__version__", "5")).split(".")[0]) >= 5
except Exception:
v5 = True
wf, fm, fs, ff = (("write-flash", "--flash-mode", "--flash-size", "--flash-freq") if v5
else ("write_flash", "--flash_mode", "--flash_size", "--flash_freq"))
args = ["--chip", CHIP, "--port", port, "--baud", FLASH_BAUD,
"--before", "default-reset", "--after", "hard-reset",
wf, fm, "dio", fs, "4MB", ff, "80m"]
for off, name in FIRMWARE_FILES:
args += [off, _resource_path("firmware", name)]
ok, msg = self._run_esptool(args, self.flash_log)
self._post(self._on_flash_done, ok, msg)
def _on_flash_done(self, ok, msg):
self._busy = False
self.flash_progress.stop()
self.flash_progress.configure(mode="determinate", value=100 if ok else 0)
self.flash_btn.configure(state="normal")
if ok:
self.flash_status.configure(text="Flash successful.")
self.flash_next_btn.configure(state="normal")
self.nb.select(2)
else:
self.flash_status.configure(text=f"Flash failed: {msg}")
# Connection trouble is the boot-mode case — make the help impossible to miss.
log_tail = self.flash_log.get("1.0", "end").lower()
if any(k in (msg or "").lower() + log_tail
for k in ("failed to connect", "no serial data", "timed out", "could not open")):
self.bootmode_box.configure(bg="#fff3cd")
self.bootmode_label.configure(bg="#fff3cd", fg="#7a5c00",
font=(UI_FONT, 11, "bold"))
# ── Finish ────────────────────────────────────────────────────────────────
def _finish(self):
self.result = {"port": self._selected_port()}
self.root.destroy()
# ── esptool runner ─────────────────────────────────────────────────────────
def _run_esptool(self, args, log_widget, tee=None):
writer = _UiWriter(self._uiq, log_widget)
sink = tee(writer) if tee else writer
old_out, old_err = sys.stdout, sys.stderr
sys.stdout = sys.stderr = sink
try:
try:
import esptool
except ImportError as e:
return False, f"esptool not in bundle: {e}"
try:
esptool.main(args)
except SystemExit as exc:
code = exc.code
if code in (0, None): return True, ""
return False, f"esptool exit {code}"
except Exception as exc:
return False, str(exc)
return True, ""
finally:
try: sink.flush()
except Exception: pass
sys.stdout, sys.stderr = old_out, old_err
# ── run ────────────────────────────────────────────────────────────────────
def run(self) -> dict | None:
self.root.protocol("WM_DELETE_WINDOW", self._cancel)
self.root.mainloop()
return self.result
def run_wizard() -> dict | None:
return SetupWizard().run()
@@ -0,0 +1,133 @@
"""TEST-V2X2MAP-VAM-001: host regression evidence; see docs/vam-support.md."""
import json
from pathlib import Path
import struct
import unittest
from its_g5_bridge import decode_itsg5_full
from vam import codec, decode_vam
VECTORS = json.loads((Path(__file__).parent / "tests/vam-vectors.json").read_text())
def packet(data, port=2018, hdr=24, length=None, transport=2):
basic = b"\x11\x00\x00\x01"
common = bytes([transport << 4, 0x50, 0, 0]) + struct.pack("!H", len(data) + 4 if length is None else length) + b"\x01\x00"
# Deliberately differs from the VAM: map values must come from ASN.1.
lpv = bytes.fromhex("0800112233445566") + struct.pack("!IiiHH", 0, 520000000, 130000000, 700, 1800)
return bytes(hdr) + bytes.fromhex("aaaa030000008947") + basic + common + lpv + bytes(4) + struct.pack("!HH", port, 0) + data
class VamTests(unittest.TestCase):
def test_independent_asn1c_vectors(self):
for vector in VECTORS:
sid, lat, lon, time, speed, heading = vector["fields"]
with self.subTest(station=sid):
vam = decode_vam(bytes.fromhex(vector["uper"]))
self.assertEqual((vam["station_id"], vam["lat"], vam["lon"], vam["generation_delta_time"]),
(sid, lat / 1e7, lon / 1e7, time))
self.assertEqual(vam.get("speed_mps"), None if speed == 16383 else speed / 100)
self.assertEqual(vam.get("heading_deg"), None if heading == 3601 else heading / 10)
self.assertNotIn("altitude_m", vam)
self.assertNotIn("longitudinal_acceleration_mps2", vam)
def test_all_truncations_and_trailing_data(self):
for vector in VECTORS:
data = bytes.fromhex(vector["uper"])
for cut in range(len(data)):
with self.subTest(cut=cut), self.assertRaises(ValueError):
decode_vam(data[:cut])
for bad in (data + b"\x00", data[:-1] + bytes([data[-1] | 1]), b"\x01" + data[1:], data[:1] + b"\x0e" + data[2:]):
with self.assertRaises(ValueError):
decode_vam(bad)
def test_optional_containers_and_json(self):
message = codec().decode("VAM", bytes.fromhex(VECTORS[1]["uper"]))
containers = message["vam"]["vamParameters"]
hf = containers["vruHighFrequencyContainer"]
hf.update(environment=3, movementControl=1, deviceUsage=4,
orientation={"value": 1800, "confidence": 10})
containers["vruLowFrequencyContainer"] = {
"profileAndSubprofile": ("bicyclistAndLightVruVehicle", 1),
"exteriorLights": {"vehicular": (b"\x80", 8), "vruSpecific": (b"\x40", 8)}}
containers["vruClusterOperationContainer"] = {"clusterIdChangeTimeInfo": 5}
containers["vruMotionPredictionContainer"] = {"pathHistory": []}
vam = decode_vam(codec().encode("VAM", message, check_constraints=True))
self.assertEqual(vam["containers"]["vruHighFrequencyContainer"]["environment"], 3)
self.assertEqual(vam["containers"]["vruLowFrequencyContainer"]["exteriorLights"]["vehicular"], ["80", 8])
json.dumps(vam) # SSE must accept CHOICE and BIT STRING data.
def test_unavailable_coordinates_and_real_zero(self):
data = bytes.fromhex(VECTORS[0]["uper"])
self.assertEqual(decode_itsg5_full(packet(data))["lat"], 0)
message = codec().decode("VAM", data)
position = message["vam"]["vamParameters"]["basicContainer"]["referencePosition"]
position["latitude"] = 900000001
result = decode_itsg5_full(packet(codec().encode("VAM", message)))
self.assertNotIn("lat", result)
self.assertNotIn("lon", result)
def test_transport_and_map_fields(self):
data = bytes.fromhex(VECTORS[1]["uper"])
for hdr in (24, 26, 30, 32):
for transport in (1, 2):
result = decode_itsg5_full(packet(data, hdr=hdr, transport=transport) + b"FCS!")
self.assertEqual(result["msg_type"], "VAM")
self.assertEqual(result["btp_dst_port"], 2018)
self.assertEqual(result["lat"], 53.9)
self.assertEqual(result["speed_mps"], 5)
self.assertEqual(result["station_type_label"], "cyclist")
for bad in (packet(data[:-1]), packet(data, length=999), packet(data, length=3), packet(data, transport=0)):
result = decode_itsg5_full(bad)
self.assertEqual(result["msg_type"], "VAM")
self.assertNotEqual(result["vam_status"], "decoded")
self.assertNotIn("lat", result)
self.assertEqual(decode_itsg5_full(packet(data, port=2001))["msg_type"], "CAM")
self.assertEqual(decode_itsg5_full(packet(data, port=2001))["lat"], 52)
def test_secured_header_is_not_guessed(self):
data = bytearray(packet(bytes.fromhex(VECTORS[0]["uper"])))
data[32] = 0x12
result = decode_itsg5_full(data)
self.assertTrue(result["secured"])
self.assertEqual(result["msg_type"], "UNKNOWN")
self.assertNotIn("lat", result)
# A signed VAM the micrOBU (ESP32-C5, vanetza-idf) transmitted on 2026-09-14, recorded by this
# bridge (recordings/its5_20260914_202525.pcap, frame 1): TS 103 097 signedData with the
# certificate inline (PSID 638), the GN common header, BTP-B 2018 and the VAM as unsecuredData.
SIGNED_VAM = bytes.fromhex("88000000ffffffffffff62d0e89fe341ffffffffffff80000300aaaa030000008947120005010381004003804c2050028000280100080062d0e89fe341d2700e411feb4f4505f4036181900b230000000007e200000310e95759360efd400aac81c2b389eea7904b0320000c5a8500058ab106418a833222204002027e00028ba605ba90dd8101018003008087e5d18581e8dbd7108300000000002ab4995b840018010480012481040301fffc80012581050401ffffff00018d8002027e81020101808082f5fc0a86649cfbebb4eb74b606659d3efea74852c742f53fa32b136f29d7ee9a8080bb6fea25df074c2db2d36bcbbeba665236668b645efbb1bdf425569f7bf3a14715edaad8e4b38c1d82368e435c861a48f146e3871cd804a56b0ff4e226d213fc8080efa94132b1e45eca98bbf0af2f391266a86920230f15faab5f15d8d37e01ef5e45c5d373470e530cc42834a7212eb94ae035829960d05db8bdd87ab87c3cd9910000007401000088")
def test_signed_vam_is_unwrapped_but_not_verified(self):
result = decode_itsg5_full(self.SIGNED_VAM)
self.assertEqual(result["msg_type"], "VAM")
self.assertTrue(result["secured"])
self.assertEqual(result["vam_status"], "decoded")
self.assertEqual(result["security"]["psid"], 638)
self.assertEqual(result["security"]["signer"], "certificate")
self.assertEqual(result["security"]["signer_id"], "ba1862d0e89fe341") # HashedId8 of the ticket
self.assertFalse(result["security"]["verified"])
self.assertEqual(result["vam"]["station_type"], 2)
self.assertEqual(result["vam"]["speed_mps"], 4.0)
self.assertEqual(result["gn_addr"], "62:d0:e8:9f:e3:41")
# the bridge never guesses: every truncation of the envelope (up to its signature; the
# receiver appends 8 octets after it) leaves the packet undecoded
from secured import unwrap_signed
envelope_end = 38 + unwrap_signed(self.SIGNED_VAM[38:])["consumed"]
for cut in range(38, envelope_end, 7):
cut_result = decode_itsg5_full(self.SIGNED_VAM[:cut])
self.assertNotEqual(cut_result.get("vam_status"), "decoded", cut)
self.assertNotIn("lat", cut_result, cut)
def test_malformed_never_falls_back_to_gn_position(self):
data = bytes.fromhex(VECTORS[0]["uper"])
for bit in range(len(data) * 8):
bad = bytearray(data)
bad[bit // 8] ^= 1 << (7 - bit % 8)
result = decode_itsg5_full(packet(bad))
if result["vam_status"] != "decoded":
self.assertNotIn("lat", result)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,46 @@
// TEST-V2X2MAP-VAM-002: exercise the dashboard's actual rendering functions.
const fs = require('node:fs');
const vm = require('node:vm');
const assert = require('node:assert/strict');
const html = fs.readFileSync(require('node:path').join(__dirname, '../dashboard.html'), 'utf8');
const script = html.split('<script>')[1].split('</script>')[0];
new vm.Script(script); // Syntax-check the entire dashboard, not only VAM code.
const slice = (a, b) => script.slice(script.indexOf(a), script.indexOf(b));
const context = vm.createContext({assert, console});
vm.runInContext(slice('const MSG =', '// ─── Map') +
slice('const vehicles ', 'setInterval(()=>{') +
slice('const filterActive=', 'document.querySelectorAll(\'.fchip\')') +
slice('function buildSummary(', 'function etCls('), context);
vm.runInContext(`
const map = {setView() {}};
const L = {
divIcon: x => x,
marker: () => ({addTo() {return this}, bindPopup() {}, on() {},
setLatLng() {}, setIcon() {}, isPopupOpen: () => false})
};
const frame = {msg_type:'VAM', station_id:'1234', lat:53.9, lon:10,
speed_mps:5, heading_deg:90, station_type_label:'cyclist',
vam:{station_id:42, station_type:2, generation_delta_time:65535,
speed_mps:5, altitude_m:12.3, longitudinal_acceleration_mps2:-0.5,
containers:{test:'<img src=x onerror=alert(1)>'}}};
onPositionFrame(frame);
onPositionFrame({...frame, lat:54});
assert.equal(vehicles.size, 1);
const marker = [...vehicles.values()][0];
assert.equal(marker.count, 2);
const popup = popupHtml(marker);
for (const text of ['Vulnerable road user (VAM)', '65535', '18.0 km/h', '12.3 m', '-0.5 m/s²', 'VAM containers'])
assert.ok(popup.includes(text), text);
assert.ok(!popup.includes('<img'));
assert.ok(popup.includes('&lt;img'));
const noValues = popupHtml({lastFrame:{...frame, vam:{station_id:0,station_type:2,generation_delta_time:0}},count:1,lastMs:0});
assert.ok(!noValues.includes('undefined'));
assert.ok(!noValues.includes('NaN'));
assert.ok(isVisible('VAM'));
filterActive.delete('VAM');
assert.ok(!isVisible('VAM'));
assert.ok(isVisible('CAM'));
assert.ok(buildSummary({lastFrame:frame,msgType:'VAM',frameCount:1}).includes('65535'));
assert.ok(buildSummary({lastFrame:{vam_status:'invalid VAM'},msgType:'VAM',frameCount:1}).includes('invalid VAM'));
console.log('PASS: VAM map updates, popups, unavailable fields, escaping, filters and log');
`, context);
@@ -0,0 +1,46 @@
[
{
"fields": [
1,
0,
0,
0,
0,
0
],
"uper": "03100000000100000009ad2748035a4e9007ffffff08eddd0f8000007e0003f50730"
},
{
"fields": [
4294967295,
539000000,
100000000,
65535,
500,
900
],
"uper": "0310ffffffffffff000aae2b2e0389fd9807ffffff08eddd0f8001c27e07d3f50730"
},
{
"fields": [
42,
-900000000,
-1800000000,
1,
16383,
3601
],
"uper": "03100000002a000100080000000000000007ffffff08eddd0f800708fefffff50730"
},
{
"fields": [
9,
900000000,
1800000000,
32768,
1,
3599
],
"uper": "0310000000098000000b5a4e9006b49d2007ffffff08eddd0f800707fe0007f50730"
}
]
@@ -0,0 +1,75 @@
2026-09-09 host verification
COMMAND: C:\bachelorthesis_micrOBU\.venv\Scripts\python.exe -W ignore::DeprecationWarning -m unittest discover -s C:\bachelorthesis_micrOBU\implementation\v2x2map-0.3.0\bridge -p test_vam.py -v
test_all_truncations_and_trailing_data (test_vam.VamTests.test_all_truncations_and_trailing_data) ... ok
test_independent_asn1c_vectors (test_vam.VamTests.test_independent_asn1c_vectors) ... ok
test_malformed_never_falls_back_to_gn_position (test_vam.VamTests.test_malformed_never_falls_back_to_gn_position) ... ok
test_optional_containers_and_json (test_vam.VamTests.test_optional_containers_and_json) ... ok
test_secured_header_is_not_guessed (test_vam.VamTests.test_secured_header_is_not_guessed) ... ok
test_transport_and_map_fields (test_vam.VamTests.test_transport_and_map_fields) ... ok
test_unavailable_coordinates_and_real_zero (test_vam.VamTests.test_unavailable_coordinates_and_real_zero) ... ok
----------------------------------------------------------------------
Ran 7 tests in 0.709s
OK
COMMAND: node C:\bachelorthesis_micrOBU\implementation\v2x2map-0.3.0\bridge\tests\dashboard-vam.cjs
PASS: VAM map updates, popups, unavailable fields, escaping, filters and log
COMMAND: C:\bachelorthesis_micrOBU\.venv\Scripts\python.exe C:\bachelorthesis_micrOBU\implementation\v2x2map-0.3.0\bridge\its_g5_bridge.py --help
usage: its_g5_bridge.py [-h] [--port PORT] [--list-ports] [--baud BAUD]
[--node-id NODE_ID] [--broker URL] [--no-mqtt]
[--reset-on-start] [--reconnect-delay RECONNECT_DELAY]
[--exit-after EXIT_AFTER]
[--dashboard-port DASHBOARD_PORT] [--open-browser]
[--no-browser] [--pcap-out PCAP_OUT] [--verbose]
ITS-G5 USB->MQTT bridge (V2X2MAP)
Config: C:\bachelorthesis_micrOBU\implementation\v2x2map-0.3.0\bridge\config\v2x2map.cfg
options:
-h, --help show this help message and exit
--port PORT serial port (e.g. COM7); omit to auto-detect or use
config
--list-ports print available serial ports with VID/PID and exit
--baud BAUD
--node-id NODE_ID device ID, e.g. V2X2MAP:d2cf13ed6293 (read from config
if omitted)
--broker URL MQTT broker URL (mqtts://host:port or
mqtt://host:port). Can be specified multiple times.
Default from config or
mqtts://cits1.opentrafficmap.org:8883
--no-mqtt parse only, don't publish (for testing)
--reset-on-start pulse RTS to reboot the device on first connect
--reconnect-delay RECONNECT_DELAY
seconds to wait before reconnecting after USB
disconnect (0 = exit)
--exit-after EXIT_AFTER
exit after N seconds (0 = run forever)
--dashboard-port DASHBOARD_PORT
local HTTP port for the live dashboard (0 to disable)
--open-browser open the dashboard URL in the default browser on start
(default: on when running as .exe)
--no-browser suppress automatic browser opening
--pcap-out PCAP_OUT write all captured frames to a .pcap file
(DLT_IEEE802_11)
--verbose, -v
PASS: Python/spec syntax, bridge CLI, Debian archive includes decoder, schemas, requirements and notices. Debian install and Windows EXE build not run.
2026-09-14 host verification (signed VAMs)
Change: bridge/secured.py unwraps TS 103 097 signed GeoNetworking packets (no signature
verification); its_g5_bridge.decode_itsg5_full continues on the unsecured payload and reports
security = {psid, hash, signer, signer_id, generation_time_ms, verified: false}.
COMMAND: C:\bachelorthesis_micrOBU\.venv\Scripts\python.exe -W ignore::DeprecationWarning -m unittest -v test_vam
Ran 8 tests ... OK (new: test_signed_vam_is_unwrapped_but_not_verified, vector = frame 1 of
recordings/its5_20260914_202525.pcap, signer HashedId8 ba1862d0e89fe341 = the micrOBU's ticket)
COMMAND: node tests/dashboard-vam.cjs
PASS
Live: bridge on COM20 (V2X2MAP receiver firmware) with the micrOBU transmitting signed VAMs on
COM11: /events carries msg_type VAM, vam_status decoded, security.signer certificate/digest.
@@ -0,0 +1,74 @@
"""VAM v3 ASN.1 UPER decoding (ETSI TS 103 300-3 V2.3.1 / CDD V2.4.1).
Pinned schemas and notices are bundled in asn1/. See ../docs/vam-support.md.
"""
from functools import lru_cache
from pathlib import Path
@lru_cache(maxsize=1)
def codec():
try:
import asn1tools
except ImportError as exc:
raise ValueError("VAM decoder unavailable: install bridge/requirements.txt") from exc
directory = Path(__file__).resolve().parent / "asn1"
return asn1tools.compile_files([
str(directory / "TS102894-2v241-CDD.asn"),
str(directory / "VAM-PDU-Descriptions.asn"),
], codec="uper")
def json_value(value):
"""Preserve ASN.1 CHOICE/bit-string values in JSON-safe form."""
if isinstance(value, bytes):
return value.hex()
if isinstance(value, dict):
return {k: json_value(v) for k, v in value.items()}
if isinstance(value, (tuple, list)):
return [json_value(v) for v in value]
return value
def decode_vam(data):
if not data or len(data) > 4096:
raise ValueError("empty/oversized VAM")
decoder = codec()
import asn1tools
try:
message = decoder.decode("VAM", data, check_constraints=True)
# Detect trailing bytes, nonzero padding and unknown extensions that the
# library may skip. Do not silently call partially understood data valid.
if decoder.encode("VAM", message, check_constraints=True) != data:
raise ValueError("noncanonical VAM or unsupported extension/trailing data")
except (asn1tools.DecodeError, asn1tools.EncodeError, asn1tools.ConstraintsError) as exc:
raise ValueError("invalid VAM UPER: " + str(exc)) from exc
header = message["header"]
if header["protocolVersion"] != 3 or header["messageId"] != 16:
raise ValueError("unsupported VAM protocol version/message ID")
body = message["vam"]
containers = body["vamParameters"]
basic = containers["basicContainer"]
pos = basic["referencePosition"]
hf = containers["vruHighFrequencyContainer"]
result = dict(station_id=header["stationId"],
generation_delta_time=body["generationDeltaTime"],
station_type=basic["stationType"],
containers=json_value(containers))
lat, lon = pos["latitude"], pos["longitude"]
if lat != 900000001 and lon != 1800000001:
result.update(lat=lat / 1e7, lon=lon / 1e7)
altitude = pos["altitude"]["altitudeValue"]
if altitude != 800001:
result["altitude_m"] = altitude / 100
heading = hf["heading"]["value"]
if heading != 3601:
result["heading_deg"] = heading / 10
speed = hf["speed"]["speedValue"]
if speed != 16383:
result["speed_mps"] = speed / 100
acceleration = hf["longitudinalAcceleration"]["longitudinalAccelerationValue"]
if acceleration != 161:
result["longitudinal_acceleration_mps2"] = acceleration / 10
return result
@@ -0,0 +1,176 @@
"""Verify the signature of secured GeoNetworking packets against configured trust anchors.
`secured.py` only unwraps the TS 103 097 envelope and says "signed, not verified". With
`--trust` the bridge also checks, per packet, whether the signature holds and whether the signer
chains to a trusted root, and the dashboard shows which. Trust comes from files the operator
names: a `VCR1` credential bundle (vanetza_idf/credentials.hpp, e.g. the MicrOBU demo chain) or
COER certificates (`.oer`). Nothing is trusted by default, so without `--trust` nothing changes.
What is checked (IEEE Std 1609.2 clause 5.3.1, ECDSA NIST P-256 with SHA-256):
* the message signature over Hash(tbsData) || Hash(signer certificate);
* a signer given as a full certificate must be signed by a trusted authority, and is then
remembered, so later messages that only carry its digest can be checked too;
* the configured chain itself at start-up: roots self-signed, authorities signed by a root,
tickets by an authority. A certificate whose signature fails is not trusted.
Not checked: validity periods, regions, permissions against the PSID, revocation. A packet this
says is "verified" was signed by a key the configured chain vouches for, nothing more.
ASN.1: IEEE1609dot2.asn / IEEE1609dot2BaseTypes.asn in asn1/, from vanetza-idf. ECDSA: the
`cryptography` package (OpenSSL).
"""
from __future__ import annotations
import hashlib
import logging
import struct
from pathlib import Path
VERIFIED, FAILED, UNKNOWN = True, False, None
def hashed_id8(octets: bytes) -> bytes:
return hashlib.sha256(octets).digest()[-8:]
def _bundle_certificates(data: bytes) -> list[bytes]:
"""Certificates of a VCR1 bundle: records [type 1][length 2 BE][payload]; types 1-3 are certificates."""
out, i = [], 4 if data[:4] == b"VCR1" else 0
while i + 3 <= len(data):
kind, length = data[i], struct.unpack(">H", data[i + 1:i + 3])[0]
if kind in (1, 2, 3):
out.append(data[i + 3:i + 3 + length])
i += 3 + length
return out
class TrustStore:
def __init__(self, paths: list[str]):
import asn1tools
directory = Path(__file__).resolve().parent / "asn1"
spec = asn1tools.compile_files([str(directory / "IEEE1609dot2.asn"),
str(directory / "IEEE1609dot2BaseTypes.asn")], "oer")
self._m = spec.modules["IEEE1609dot2"]
certificates = []
for path in paths:
data = Path(path).read_bytes()
certificates += _bundle_certificates(data) if data[:4] == b"VCR1" else [data]
self.issuers: dict[bytes, bytes] = {} # digest -> roots and authorities whose signature held
self.signers: dict[bytes, bytes] = {} # digest -> tickets (and certificates learned on air)
pending = list(certificates)
# Roots first, then whatever they vouch for, until nothing more can be placed.
while pending:
placed = False
for cert in list(pending):
verdict = self._place(cert)
if verdict is not None:
pending.remove(cert)
placed = True
if not placed:
break
for cert in pending:
logging.warning("trust: %s not trusted (issuer unknown or signature fails)", hashed_id8(cert).hex().upper())
logging.info("trust: %d root/authority certificate(s), %d signer(s): %s", len(self.issuers), len(self.signers),
", ".join(d.hex().upper() for d in list(self.issuers) + list(self.signers)))
# ---- certificates --------------------------------------------------------------------
def _decode(self, cert: bytes) -> dict:
return self._m["Certificate"].decode(cert)
def _is_authority(self, decoded: dict) -> bool:
return "certIssuePermissions" in decoded["toBeSigned"]
def _place(self, cert: bytes):
"""Trusts cert if its signature holds under a trusted issuer (or itself, for a root). None if not yet."""
decoded = self._decode(cert)
kind, issuer = decoded["issuer"]
if kind == "self":
issuer_cert = None
elif kind in ("sha256AndDigest",):
issuer_cert = self.issuers.get(bytes(issuer))
if issuer_cert is None:
return None
else:
return None
if not self._certificate_signature_ok(cert, decoded, issuer_cert):
return False
target = self.issuers if kind == "self" or self._is_authority(decoded) else self.signers
target[hashed_id8(cert)] = cert
return True
def _public_key(self, decoded: dict):
from cryptography.hazmat.primitives.asymmetric import ec
kind, key = decoded["toBeSigned"]["verifyKeyIndicator"]
if kind != "verificationKey" or key[0] != "ecdsaNistP256":
raise ValueError("verification key is not ECDSA NIST P-256")
form, point = key[1]
if form == "compressed-y-0":
encoded = b"\x02" + point
elif form == "compressed-y-1":
encoded = b"\x03" + point
elif form == "uncompressedP256":
encoded = b"\x04" + point["x"] + point["y"]
else:
raise ValueError("unsupported point form " + form)
return ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), encoded)
@staticmethod
def _ecdsa_ok(public_key, message: bytes, signature) -> bool:
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
kind, sig = signature
if kind != "ecdsaNistP256Signature":
return False
_, r = sig["rSig"]
r_x = r if isinstance(r, (bytes, bytearray)) else r["x"] # x-only or compressed: r is x
der = encode_dss_signature(int.from_bytes(r_x, "big"), int.from_bytes(sig["sSig"], "big"))
try:
public_key.verify(der, message, ec.ECDSA(hashes.SHA256()))
return True
except InvalidSignature:
return False
def _certificate_signature_ok(self, cert: bytes, decoded: dict, issuer_cert: bytes | None) -> bool:
if "signature" not in decoded:
return False
tbs = self._m["ToBeSignedCertificate"].encode(decoded["toBeSigned"])
signer_input = hashlib.sha256(issuer_cert if issuer_cert is not None else b"").digest()
key = self._public_key(self._decode(issuer_cert) if issuer_cert is not None else decoded)
return self._ecdsa_ok(key, hashlib.sha256(tbs).digest() + signer_input, decoded["signature"])
# ---- messages ------------------------------------------------------------------------
def verify(self, envelope: bytes) -> dict:
"""{"verified": True | False | None, "reason": str, "signer_id": hex | None} for one Ieee1609Dot2Data."""
try:
data = self._m["Ieee1609Dot2Data"].decode(envelope)
kind, signed = data["content"]
if kind != "signedData":
return {"verified": UNKNOWN, "reason": "not signedData"}
signer_kind, signer = signed["signer"]
if signer_kind == "digest":
digest = bytes(signer)
cert = self.signers.get(digest)
if cert is None:
return {"verified": UNKNOWN, "reason": "signer not known here", "signer_id": digest.hex().upper()}
elif signer_kind == "certificate":
cert = self._m["Certificate"].encode(signer[0])
digest = hashed_id8(cert)
if digest not in self.signers:
placed = self._place(cert)
if placed is None:
return {"verified": UNKNOWN, "reason": "issuer not trusted here", "signer_id": digest.hex().upper()}
if placed is False:
return {"verified": FAILED, "reason": "certificate signature fails", "signer_id": digest.hex().upper()}
else:
return {"verified": UNKNOWN, "reason": "self-signed message"}
tbs = self._m["ToBeSignedData"].encode(signed["tbsData"])
message = hashlib.sha256(tbs).digest() + hashlib.sha256(cert).digest()
ok = self._ecdsa_ok(self._public_key(self._decode(cert)), message, signed["signature"])
return {"verified": VERIFIED if ok else FAILED,
"reason": "signature valid" if ok else "signature does not verify",
"signer_id": digest.hex().upper()}
except Exception as exc: # a packet this cannot decode is unknown, never "verified"
return {"verified": UNKNOWN, "reason": "not checkable: %s" % exc}