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:
@@ -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 ± 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
|
||||
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Binary file not shown.
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('<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}
|
||||
Reference in New Issue
Block a user