Compare commits
3
Commits
python-final
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2f78080e0 | ||
|
|
8f667dc6cf | ||
|
|
54242da04b |
No files matched your search
@@ -0,0 +1,4 @@
|
||||
# keep test data byte-exact on every platform
|
||||
*.bin binary
|
||||
*.pcap binary
|
||||
tests/golden/** -text
|
||||
+9
-18
@@ -1,27 +1,18 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
|
||||
# UV
|
||||
uv
|
||||
uv.lock
|
||||
# Rust
|
||||
target/
|
||||
|
||||
# Captures (binary data, not tracked)
|
||||
captures/
|
||||
|
||||
# Lamp export bundles (published to the website repo instead)
|
||||
# left over from the Python tools
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
.venv
|
||||
uv
|
||||
uv.lock
|
||||
lamps/
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
|
||||
# Local AI-tooling state
|
||||
.claude/
|
||||
.claude-flow/
|
||||
.swarm/
|
||||
@@ -1 +0,0 @@
|
||||
3.13
|
||||
Generated
+832
@@ -0,0 +1,832 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"num-traits",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "ctrlc"
|
||||
version = "3.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162"
|
||||
dependencies = [
|
||||
"dispatch2",
|
||||
"nix 0.31.3",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch2"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hpcs6500"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"ctrlc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serialport",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "io-kit-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"mach2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.191"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
||||
|
||||
[[package]]
|
||||
name = "libudev"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libudev-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libudev-sys"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "mach2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
|
||||
dependencies = [
|
||||
"objc2-encode",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serialport"
|
||||
version = "4.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba5f8f29aa20853c4e3e85a33ec580eb66be1f057142e77a333834a318bacf2"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"io-kit-sys",
|
||||
"libudev",
|
||||
"mach2",
|
||||
"nix 0.26.4",
|
||||
"scopeguard",
|
||||
"unescaper",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unescaper"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7285e83a80ce76f5e7bce79fa41f68d78ba62d1003cf27bf748ab24413808cf4"
|
||||
dependencies = [
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
[package]
|
||||
name = "hpcs6500"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
description = "Open-source driver and CLI for the HPCS 6500 spectrophotometer / integrating sphere"
|
||||
repository = "https://git.b4l.co.th/B4L/HPCS6500"
|
||||
readme = "README.md"
|
||||
publish = false
|
||||
|
||||
[features]
|
||||
default = ["cli"]
|
||||
# the `hpcs6500` command line tool; library users can turn it off
|
||||
cli = ["dep:clap", "dep:serde_json", "dep:chrono", "dep:ctrlc"]
|
||||
|
||||
[lib]
|
||||
name = "hpcs6500"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "hpcs6500"
|
||||
path = "src/cli/main.rs"
|
||||
required-features = ["cli"]
|
||||
|
||||
[dependencies]
|
||||
serialport = "4.7"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
thiserror = "2"
|
||||
clap = { version = "4.5", features = ["derive"], optional = true }
|
||||
serde_json = { version = "1", features = ["preserve_order"], optional = true }
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "std"], optional = true }
|
||||
ctrlc = { version = "3.4", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { version = "1", features = ["preserve_order"] }
|
||||
|
||||
+15
-1
@@ -1,6 +1,7 @@
|
||||
# HPCS 6500 USB Protocol Reference
|
||||
|
||||
Reverse-engineered from USB packet captures of the vendor software.
|
||||
Reverse-engineered from USB packet captures of the vendor software. The
|
||||
Rust driver in `src/` implements it (`protocol.rs`, `device.rs`, `reading.rs`).
|
||||
|
||||
## Device Overview
|
||||
|
||||
@@ -189,6 +190,19 @@ The 350 floats cover **380 nm to 1050 nm** at ~1.92 nm steps
|
||||
((1050-380) / (350-1) = 1.917 nm/step). Values are absolute spectral
|
||||
irradiance; the vendor software normalizes by dividing by the peak value.
|
||||
|
||||
> **Unverified: the wavelength axis.** The linear 380-1050 nm mapping above
|
||||
> is an assumption. Integrating the published lamp spectra with the CIE 1931
|
||||
> observer on this axis gives chromaticities 0.09-0.13 (xy) away from the
|
||||
> x, y the instrument itself reports in the same block (offsets 52/56); for
|
||||
> L0011 the spectrum says x,y = 0.358, 0.295 against the instrument's 0.380,
|
||||
> 0.382. No other linear range fits all lamps (380-780 nm comes closer but
|
||||
> moves the blue LED peak to ~419 nm). The 350 points are probably on a
|
||||
> non-linear (spectrometer pixel) axis. Everything computed from the
|
||||
> spectrum (TM-30 Rf/Rg, the spd.csv files) inherits this until the axis is
|
||||
> confirmed, e.g. against a spectrum exported by the vendor software with
|
||||
> its wavelengths, or by fitting the axis so the spectrum reproduces the
|
||||
> instrument's own XYZ.
|
||||
|
||||
### Unmapped Regions
|
||||
|
||||
Offsets 176-223, 256-271, and 1832-3903 contain additional data not yet
|
||||
|
||||
@@ -1,170 +1,152 @@
|
||||
# HPCS 6500 — Open-Source Driver
|
||||
# HPCS 6500 — open-source driver
|
||||
|
||||
Python driver and CLI for the HPCS 6500 spectrophotometer / integrating sphere.
|
||||
Rust driver and command line tool for the HPCS 6500 spectrophotometer /
|
||||
integrating sphere. It talks to the instrument directly over its USB serial
|
||||
port and replaces the vendor software for measurement automation and data
|
||||
extraction.
|
||||
|
||||
Communicates directly with the instrument over USB serial, replacing the
|
||||
vendor software for measurement automation and data extraction.
|
||||
This repository is only the driver. The lamp measurement station, the lamp
|
||||
comparison export (TM-30, label printing) and the LED light calibration are
|
||||
in the testbench repository,
|
||||
[B4L/lamp-testbench](https://git.b4l.co.th/B4L/lamp-testbench), which pulls
|
||||
this repository in as a submodule (the same layout as
|
||||
[mppt-testbench](https://git.b4l.co.th/B4L/mppt-testbench)).
|
||||
|
||||
## What This Does
|
||||
## What it reads
|
||||
|
||||
- **Measure**: Luminous flux (lm), CCT (K), CRI (Ra, R1-R15), chromaticity
|
||||
(CIE xy, uv, u'v'), radiometric flux, spectrum 380-1050 nm
|
||||
- **Electrical**: Voltage, current, power, power factor, frequency
|
||||
- **Harmonics**: 50-harmonic voltage/current analysis, UThd, AThd, waveforms
|
||||
- **Power supply**: Control the built-in AC (100-240V, 50/60Hz) and DC
|
||||
(1-60V, 0-5A) power supply
|
||||
- **Export**: CSV output for data logging
|
||||
- **TM-30**: ANSI/IES TM-30-18 Rf, Rg, and hue-bin data computed from the
|
||||
measured spectrum (via colour-science)
|
||||
- **Lamp bundles**: one-command export of spectrum + TM-30 + metrics for the
|
||||
[buildfor.life lamp comparison](https://buildfor.life/comparisons)
|
||||
- **Photometric**: luminous flux (lm), efficacy (lm/W), CCT (K), Duv
|
||||
- **Colour**: CRI Ra and R1-R15, SDCM, TLCI-2012, CIE xy / uv / u'v', CIE 1931 XYZ
|
||||
- **Radiometric**: total, UV, blue, yellow, red, far-red and IR flux (mW)
|
||||
- **Spectrum**: 350 points, 380-1050 nm
|
||||
- **Electrical**: voltage, current, power, power factor, frequency
|
||||
- **Harmonics**: 50 voltage and current harmonics, UThd, AThd, one cycle of both waveforms
|
||||
- **Power supply**: the built-in AC (100-240 V, 50/60 Hz) and DC (1-60 V, 0-5 A) supply
|
||||
|
||||
## Quick Start
|
||||
## Install
|
||||
|
||||
```bash
|
||||
# Install
|
||||
uv sync
|
||||
|
||||
# Single measurement (auto-detects device, turns PSU on/off)
|
||||
uv run hpcs6500.py
|
||||
|
||||
# Continuous measurements
|
||||
uv run hpcs6500.py --continuous
|
||||
|
||||
# Quick test (lumen + CCT only)
|
||||
uv run hpcs6500.py --quick
|
||||
uv run hpcs6500.py --quick --continuous
|
||||
|
||||
# Show full spectrum + harmonics
|
||||
uv run hpcs6500.py --spectrum --harmonics
|
||||
|
||||
# Save to CSV
|
||||
uv run hpcs6500.py --continuous --csv output.csv
|
||||
cargo install --git https://git.b4l.co.th/B4L/HPCS6500 # puts `hpcs6500` on the PATH
|
||||
# or, in a checkout:
|
||||
cargo build --release # target/release/hpcs6500(.exe)
|
||||
```
|
||||
|
||||
## Power Supply Control
|
||||
Windows needs no driver: the instrument is an STM32 virtual COM port
|
||||
(VID `0483`, PID `5741`) and is found automatically. Linux: add yourself to
|
||||
the `dialout` group.
|
||||
|
||||
## Command line
|
||||
|
||||
The flags are the ones of the former Python tool: `uv run hpcs6500.py ...`
|
||||
is now `hpcs6500 ...`.
|
||||
|
||||
```bash
|
||||
# Read current PSU settings
|
||||
uv run hpcs6500.py --psu-status
|
||||
hpcs6500 # single reading (vendor single-test flow)
|
||||
hpcs6500 --continuous # continuous readings, PSU on while measuring
|
||||
hpcs6500 --continuous --count 10 --csv out.csv
|
||||
hpcs6500 --quick --continuous # lumen + CCT only
|
||||
hpcs6500 --spectrum --harmonics # full spectrum and harmonics
|
||||
hpcs6500 --json # one JSON object per reading on stdout
|
||||
hpcs6500 --passive --continuous # read only, while the vendor software drives the instrument
|
||||
hpcs6500 --port COM42 # pick the port
|
||||
hpcs6500 ports # list serial ports, the instrument is marked
|
||||
|
||||
# Set AC mode, 230V 50Hz
|
||||
uv run hpcs6500.py --mode ac --voltage 230 --frequency 50
|
||||
|
||||
# Set DC mode, 12V with 1A current limit
|
||||
uv run hpcs6500.py --mode dc --voltage 12 --current 1.0
|
||||
|
||||
# Manual PSU on/off
|
||||
uv run hpcs6500.py --psu-on
|
||||
uv run hpcs6500.py --psu-off
|
||||
|
||||
# Set integration time (ms)
|
||||
uv run hpcs6500.py --integration 500
|
||||
# power supply
|
||||
hpcs6500 --psu-status
|
||||
hpcs6500 --mode ac --voltage 230 --frequency 50
|
||||
hpcs6500 --mode dc --voltage 12 --current 1.0
|
||||
hpcs6500 --psu-on / hpcs6500 --psu-off
|
||||
hpcs6500 --integration 500 # ms, 0 = auto
|
||||
```
|
||||
|
||||
## Lamp Comparison Export
|
||||
Ctrl+C stops a continuous run cleanly: the supply is switched off and the
|
||||
instrument reset. The text output and the CSV files are character for
|
||||
character those of the Python tool, so scripts that read them keep working.
|
||||
|
||||
Produces the per-lamp data bundle consumed by the buildfor.life comparison
|
||||
pages: `spd.csv` (full 380-1050 nm spectrum), `tm30.csv` (TM-30-18 hue-bin
|
||||
data for the color vector graphic), and `metrics.json` (photometric,
|
||||
colorimetric, CRI R1-R15, TM-30 Rf/Rg, electrical).
|
||||
### Without the instrument
|
||||
|
||||
`--simulate` talks to a simulated instrument that answers every command of
|
||||
the protocol: with a capture it replays the readings recorded in it,
|
||||
without one it uses a built-in sample reading.
|
||||
|
||||
```bash
|
||||
# Single reading from the device
|
||||
uv run lamp_export.py --name philips-a60-8w --manufacturer Philips --model "A60 8W 927"
|
||||
|
||||
# Average several readings
|
||||
uv run lamp_export.py --name philips-a60-8w --readings 5
|
||||
|
||||
# Power the lamp from the built-in supply: 230 V / 50 Hz, 60 s warm-up,
|
||||
# PSU switches on before the readings and off afterwards
|
||||
uv run lamp_export.py --name philips-a60-8w --voltage 230 --frequency 50 --settle 60
|
||||
|
||||
# From an existing pcap capture
|
||||
uv run lamp_export.py --name some-lamp --parse captures/run.pcap
|
||||
hpcs6500 --simulate # built-in sample
|
||||
hpcs6500 --simulate captures/run.pcap --continuous # replay a capture
|
||||
```
|
||||
|
||||
Output lands in `lamps/<name>/`. TM-30 is computed from the measured spectrum
|
||||
with [colour-science](https://www.colour-science.org/); the spectrum is
|
||||
relative, which TM-30 is invariant to. Sanity check of the implementation:
|
||||
`uv run tm30.py` reproduces the published values for the CIE FL2 illuminant
|
||||
(Rf 70, Rg 86).
|
||||
|
||||
## Lamp Station (barcode workflow)
|
||||
|
||||
Interactive loop around `lamp_export.py` for building the comparison database
|
||||
end to end. Every lamp gets an internal ID (L0001, L0002, ...) as primary key;
|
||||
the EAN barcode is optional metadata, so one barcode can cover daylight/warm
|
||||
white variants (distinct IDs + variant field) and unbranded lamps work too.
|
||||
|
||||
Every run opens with a freshly allocated ID (the ID only becomes real once a
|
||||
bundle is written, so aborting never burns a number). Then: scan the box's EAN
|
||||
(optional, Enter skips it), or type an existing ID like L0004 to re-measure
|
||||
that lamp with everything pre-filled, which also rewrites its bundle in the
|
||||
current metrics format. Scanned EANs are checksum-validated and resolved to
|
||||
manufacturer/model (previously measured lamps, local cache, then
|
||||
upcitemdb.com; GS1-restricted 20-29 barcodes are flagged as
|
||||
retailer-internal), lamps already measured under the same EAN are offered for
|
||||
re-measurement, you confirm the identification and type the ADVERTISED values
|
||||
from the packaging (flux, CCT, power, CRI, lifetime, equivalent W -> stored
|
||||
under "rated" in metrics.json for claimed-vs-measured comparison), insert the
|
||||
lamp, and the tool measures per the published procedure (230 V / 50 Hz, 60 s
|
||||
settle, 5 readings averaged), writes lamps/<ID>/ into the comparison-data
|
||||
checkout, commits, and pushes, then prints two ID labels (QR code linking to
|
||||
the lamp's page plus the ID, DK-11221 23x23 mm) on a Brother QL-820NWB network
|
||||
printer: one for the box, one for the lamp. Set HPCS_LABEL_PRINTER (e.g.
|
||||
tcp://192.168.1.50) or pass --printer; reprint anytime with
|
||||
`uv run label_printer.py <ID>`. The website picks the lamp up automatically:
|
||||
its device list is data-driven from the comparison-data bundles and the push
|
||||
triggers the auto-bump deploy. An .md page in the web repo is only ever needed
|
||||
for photos, where-to-buy links, or notes.
|
||||
### USB captures (reverse engineering)
|
||||
|
||||
```bash
|
||||
uv run lamp_station.py # scan -> measure -> commit -> push
|
||||
uv run lamp_station.py --no-push # commit locally only
|
||||
uv run lamp_station.py --no-commit # just write the bundle
|
||||
uv run lamp_station.py --settle 0 --readings 1 # quick smoke test
|
||||
hpcs6500 --parse captures/run.pcap [--quick] [--spectrum] [--json] # readings in a capture
|
||||
hpcs6500 dump captures/run.pcap # traffic timeline + <name>_TX.bin / _RX.bin
|
||||
hpcs6500 capture # record the vendor software with USBPcap (admin, Windows)
|
||||
```
|
||||
|
||||
Defaults assume `C:/dev/buildfor_life_web` with the comparison-data submodule
|
||||
initialized; override with --data-repo.
|
||||
`capture` starts `USBPcapCMD` on every root hub, waits for Ctrl+C, keeps the
|
||||
largest file and dumps it. Captures go to `captures/` (not in git).
|
||||
|
||||
## Offline Parsing
|
||||
## Library
|
||||
|
||||
Parse previously captured USB traffic (pcap files from USBPcap):
|
||||
|
||||
```bash
|
||||
uv run hpcs6500.py --parse captures/some_capture.pcap
|
||||
uv run hpcs6500.py --parse captures/some_capture.pcap --quick
|
||||
```toml
|
||||
[dependencies]
|
||||
hpcs6500 = { git = "https://git.b4l.co.th/B4L/HPCS6500", default-features = false }
|
||||
```
|
||||
|
||||
## Files
|
||||
`default-features = false` leaves out the command line tool's dependencies.
|
||||
|
||||
| File | Description |
|
||||
|------------------|----------------------------------------------------|
|
||||
| `hpcs6500.py` | Driver class (`HPCS6500`) and CLI |
|
||||
| `lamp_export.py` | Lamp comparison bundle export (spd/tm30/metrics) |
|
||||
| `tm30.py` | ANSI/IES TM-30-18 computation from a spectrum |
|
||||
| `usb_capture.py` | USB traffic capture tool (requires USBPcap) |
|
||||
| `PROTOCOL.md` | Complete protocol reference (byte-level) |
|
||||
| `pyproject.toml` | Project metadata and dependencies |
|
||||
```rust
|
||||
use hpcs6500::{Hpcs6500, Mode};
|
||||
|
||||
## Hardware
|
||||
let port = hpcs6500::find_port().ok_or(hpcs6500::Error::NotFound)?;
|
||||
let mut dev = Hpcs6500::open(&port)?;
|
||||
dev.identify()?; // "HPCS6500"
|
||||
dev.set_mode(Mode::Ac)?;
|
||||
dev.set_ac_voltage(230.0)?;
|
||||
dev.set_ac_frequency(50.0)?;
|
||||
dev.psu_on()?;
|
||||
dev.send_test_config(false)?; // the vendor's single-test configuration
|
||||
let r = dev.take_single_reading()?; // trigger, poll, read both blocks, reset
|
||||
dev.psu_off()?;
|
||||
println!("{:.0} lm {:.0} K Ra {:.1}", r.flux_lm(), r.cct_k(), r.ra());
|
||||
println!("{:?}", r.get("Phi_eb_mW")); // every field by its CSV name
|
||||
let nm = r.spectrum_nm(); // with r.spectrum
|
||||
```
|
||||
|
||||
- **Device**: HPCS 6500 spectrophotometer / integrating sphere
|
||||
- **USB**: STM32 Virtual COM Port (VID `0483`, PID `5741`)
|
||||
- **Protocol**: Custom binary over serial, documented in [PROTOCOL.md](PROTOCOL.md)
|
||||
| Item | |
|
||||
|---|---|
|
||||
| `Hpcs6500::open(port)` / `with_transport(t, name)` | the instrument over the serial port or any `Transport` |
|
||||
| `identify`, `poll_state`, `wait_for_data` | |
|
||||
| `take_single_reading`, `take_reading(psu)`, `read_current` | single test, one continuous reading, passive read |
|
||||
| `start_measurement`, `stop_measurement`, `reset`, `send_test_config` | the raw sequence steps |
|
||||
| `psu_on`, `psu_off`, `set_mode`, `set_ac_voltage`, `set_ac_frequency`, `set_dc_voltage`, `set_dc_current`, `read_psu_settings` | built-in supply |
|
||||
| `set_integration_time(ms)` | 0 = auto |
|
||||
| `read_measurement_block`, `read_electrical_block` | raw 3904 / 1584-byte blocks |
|
||||
| `set_cancel_flag(flag)`, `pause(d)` | long waits end with `Error::Cancelled` when the flag goes up (Ctrl+C) |
|
||||
| `Reading` | `values` by `FIELDS`, `get(name)`, `scalars()`, `spectrum`, `spectrum_nm()`, `electrical` with `harmonics`; serializes to the Python driver's JSON keys |
|
||||
| `pcap::read_messages`, `pcap::readings`, `pcap::blocks` | USBPcap captures |
|
||||
| `sim::Simulator` | simulated instrument (`sample()`, `from_pcap(path)`), logs every frame |
|
||||
| `format::*` | the text report and the CSV export |
|
||||
| `pyfmt::*` | Python-compatible number formatting (`repr`, `.6g`, `.2f`, `round`) |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Python 3.11+
|
||||
- `pyserial` (serial communication)
|
||||
- `colour-science` (TM-30 computation)
|
||||
- USBPcap (only for `usb_capture.py`, not needed for normal operation)
|
||||
Every method returns `Result`; errors say what was expected and what came
|
||||
back (`no acknowledge for command 0x72 (got nothing)`).
|
||||
|
||||
## Protocol
|
||||
|
||||
The binary protocol is fully documented in [PROTOCOL.md](PROTOCOL.md),
|
||||
including all command bytes, data block layouts, field offsets, and the
|
||||
complete measurement sequence. This was reverse-engineered from USB
|
||||
packet captures of the vendor software.
|
||||
The binary protocol is documented byte by byte in [PROTOCOL.md](PROTOCOL.md):
|
||||
command bytes, block layouts, field offsets and the measurement sequences.
|
||||
It was reverse-engineered from USB captures of the vendor software.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cargo test # protocol, simulator, golden tests
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
`tests/golden/` holds the output of the former Python driver for real
|
||||
readings (`tests/fixtures/`): the parsed values (bit-exact), the text report,
|
||||
the CSV row and 2,000 number-formatting cases. The Rust driver reproduces all
|
||||
of them exactly. Where `captures/` is present, the `--parse` reports of real
|
||||
captures are compared too.
|
||||
|
||||
The Python driver this replaces is at tag `python-final`.
|
||||
-895
@@ -1,895 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
LED Light Calibration Tool
|
||||
|
||||
Calibrates the bilinear interpolation table for CCT/Lumen mixing ratios.
|
||||
Works with an external measurement script that writes to measurement.json.
|
||||
|
||||
Usage:
|
||||
1. Start the external measurement script (reads spectrometer/lux meter)
|
||||
2. Run this calibration tool
|
||||
3. The tool sets test points and waits for measurements
|
||||
4. After all points are measured, it generates a calibration table
|
||||
"""
|
||||
|
||||
import serial
|
||||
import time
|
||||
import json
|
||||
import sys
|
||||
import argparse
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
# Calibration grid (matches firmware)
|
||||
CCT_POINTS = [2600, 2900, 3200, 3500, 3800, 4100, 4400, 4700, 5000]
|
||||
LUMEN_POINTS = [200, 375, 550, 725, 900]
|
||||
NUM_CCT = len(CCT_POINTS)
|
||||
NUM_LUMEN = len(LUMEN_POINTS)
|
||||
|
||||
# UART commands
|
||||
CMD_SET_COLOR = 0x81
|
||||
CMD_SET_INTENSITY = 0x82
|
||||
CMD_PROFILE_OFF = 0x84
|
||||
CMD_CAL_START = 0x8A
|
||||
CMD_CAL_WRITE = 0x8B
|
||||
CMD_CAL_COMMIT = 0x8C
|
||||
CMD_CAL_CLEAR = 0x8D
|
||||
CMD_CAL_READ = 0x8E
|
||||
CMD_CAL_WRITE_Y = 0x8F
|
||||
RSP_ACK = 0xC0
|
||||
|
||||
# Default y-data tables (mA points: 0, 11, 33, 65, 150 -> lumen output)
|
||||
Y27_DEFAULT = [0, 39, 179, 369, 844]
|
||||
Y50_DEFAULT = [0, 51, 217, 441, 969]
|
||||
X_DATA = [0, 11, 33, 65, 150] # mA drive points
|
||||
|
||||
# Default paths
|
||||
CALIBRATION_OUTPUT = Path(__file__).parent / "calibration_table.json"
|
||||
DEFAULT_SPECTROMETER = Path(__file__).parent / "spectrometer.py"
|
||||
|
||||
|
||||
class CalibrationTool:
|
||||
def __init__(self, port, baudrate=115200, spectrometer_script=None):
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self.spectrometer_script = spectrometer_script or DEFAULT_SPECTROMETER
|
||||
self.ser = None
|
||||
self.measurements = {} # (cct, lumen) -> {'measured_cct': x, 'measured_lumen': y}
|
||||
|
||||
def connect(self):
|
||||
"""Connect to the light via serial port."""
|
||||
self.ser = serial.Serial(self.port, self.baudrate, timeout=1)
|
||||
time.sleep(0.5) # Wait for connection to stabilize
|
||||
self.ser.reset_input_buffer()
|
||||
print(f"Connected to {self.port}")
|
||||
|
||||
def disconnect(self):
|
||||
"""Disconnect from the light."""
|
||||
if self.ser:
|
||||
self.ser.close()
|
||||
self.ser = None
|
||||
|
||||
def send_command(self, cmd, payload, timeout=2.0):
|
||||
"""Send a 5-byte command and wait for response, skipping diagnostic messages."""
|
||||
if len(payload) != 4:
|
||||
raise ValueError("Payload must be 4 bytes")
|
||||
|
||||
# Flush any pending input
|
||||
self.ser.reset_input_buffer()
|
||||
|
||||
data = bytes([cmd]) + bytes(payload)
|
||||
self.ser.write(data)
|
||||
|
||||
# Read 5-byte messages until we get a response (0xC0 or 0xC1)
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
response = self.ser.read(5)
|
||||
if len(response) < 5:
|
||||
continue
|
||||
|
||||
# Check if this is a command response (0xC0=ACK, 0xC1=NAK)
|
||||
if response[0] == RSP_ACK:
|
||||
# Verify the echoed command matches what we sent
|
||||
if response[1] == cmd:
|
||||
return True
|
||||
else:
|
||||
print(f"ACK mismatch: sent 0x{cmd:02x}, got {response.hex()}")
|
||||
# Keep reading, this might be from a previous command
|
||||
continue
|
||||
elif response[0] == 0xC1: # RSP_NAK
|
||||
print(f"NAK received: {response.hex()}")
|
||||
return False
|
||||
# Otherwise it's a diagnostic message, skip it
|
||||
|
||||
print("Timeout waiting for response")
|
||||
return False
|
||||
|
||||
def set_intensity(self, intensity):
|
||||
"""Set light intensity (0-1000 lux target)."""
|
||||
lo = intensity & 0xFF
|
||||
hi = (intensity >> 8) & 0xFF
|
||||
return self.send_command(CMD_SET_INTENSITY, [lo, hi, 0, 0])
|
||||
|
||||
def set_color(self, cct):
|
||||
"""Set color temperature (2600-5000K)."""
|
||||
lo = cct & 0xFF
|
||||
hi = (cct >> 8) & 0xFF
|
||||
return self.send_command(CMD_SET_COLOR, [lo, hi, 0, 0])
|
||||
|
||||
def disable_profile(self):
|
||||
"""Disable automatic profile, switch to manual mode."""
|
||||
return self.send_command(CMD_PROFILE_OFF, [0, 0, 0, 0])
|
||||
|
||||
def cal_start(self):
|
||||
"""Start calibration write sequence (erases flash)."""
|
||||
result = self.send_command(CMD_CAL_START, [0, 0, 0, 0], timeout=5.0)
|
||||
print(f" cal_start result: {result}")
|
||||
return result
|
||||
|
||||
def cal_write(self, index, value):
|
||||
"""Write a single calibration value (index 0-44, value 0-255)."""
|
||||
result = self.send_command(CMD_CAL_WRITE, [index, value, 0, 0])
|
||||
if not result:
|
||||
print(f" cal_write failed at index {index}, value {value}")
|
||||
return result
|
||||
|
||||
def cal_write_y(self, table, index, value):
|
||||
"""Write a y-data calibration value. table: 0=y27, 1=y50. value: uint16."""
|
||||
lo = value & 0xFF
|
||||
hi = (value >> 8) & 0xFF
|
||||
return self.send_command(CMD_CAL_WRITE_Y, [table, index, lo, hi])
|
||||
|
||||
def cal_commit(self):
|
||||
"""Commit calibration data to flash."""
|
||||
return self.send_command(CMD_CAL_COMMIT, [0, 0, 0, 0], timeout=5.0)
|
||||
|
||||
def cal_clear(self):
|
||||
"""Clear calibration (revert to defaults)."""
|
||||
return self.send_command(CMD_CAL_CLEAR, [0, 0, 0, 0])
|
||||
|
||||
def write_calibration_table(self, table, y27_cal=None, y50_cal=None, verbose=True):
|
||||
"""Write calibration data to the device.
|
||||
|
||||
Args:
|
||||
table: 9x5 mixing ratio table
|
||||
y27_cal: 5-element list of lumen values for 2700K LED (optional)
|
||||
y50_cal: 5-element list of lumen values for 5000K LED (optional)
|
||||
"""
|
||||
if verbose:
|
||||
print("Writing calibration to device...")
|
||||
|
||||
# Ensure clean UART state before starting
|
||||
self.ser.reset_input_buffer()
|
||||
time.sleep(0.1)
|
||||
|
||||
if not self.cal_start():
|
||||
if verbose:
|
||||
print(" Failed to start calibration write")
|
||||
return False
|
||||
|
||||
time.sleep(1.0) # Wait for flash erase to complete
|
||||
|
||||
# Write mixing ratios
|
||||
index = 0
|
||||
for i, row in enumerate(table):
|
||||
for j, value in enumerate(row):
|
||||
if not self.cal_write(index, value):
|
||||
if verbose:
|
||||
print(f" Failed to write mixing ratio index {index}")
|
||||
return False
|
||||
index += 1
|
||||
time.sleep(0.05)
|
||||
|
||||
# Write y27 data (use defaults if not provided)
|
||||
y27 = y27_cal if y27_cal else Y27_DEFAULT
|
||||
for i, value in enumerate(y27):
|
||||
if not self.cal_write_y(0, i, int(value)):
|
||||
if verbose:
|
||||
print(f" Failed to write y27[{i}]")
|
||||
return False
|
||||
time.sleep(0.05)
|
||||
|
||||
# Write y50 data (use defaults if not provided)
|
||||
y50 = y50_cal if y50_cal else Y50_DEFAULT
|
||||
for i, value in enumerate(y50):
|
||||
if not self.cal_write_y(1, i, int(value)):
|
||||
if verbose:
|
||||
print(f" Failed to write y50[{i}]")
|
||||
return False
|
||||
time.sleep(0.05)
|
||||
|
||||
time.sleep(0.5) # Wait before commit
|
||||
|
||||
if not self.cal_commit():
|
||||
if verbose:
|
||||
print(" Failed to commit calibration")
|
||||
return False
|
||||
|
||||
if verbose:
|
||||
print(" Calibration written successfully!")
|
||||
return True
|
||||
|
||||
def cal_read(self, index):
|
||||
"""Read a single calibration value. Returns (index, value, valid) or None."""
|
||||
self.ser.write(bytes([CMD_CAL_READ, index, 0, 0, 0]))
|
||||
response = self.ser.read(5)
|
||||
if len(response) < 5 or response[0] != RSP_ACK:
|
||||
return None
|
||||
return (response[1], response[2], response[3])
|
||||
|
||||
def read_calibration_table(self, verbose=True):
|
||||
"""Read the entire calibration table from device."""
|
||||
if verbose:
|
||||
print("Reading calibration table from device...")
|
||||
table = []
|
||||
valid = None
|
||||
for i in range(NUM_CCT):
|
||||
row = []
|
||||
for j in range(NUM_LUMEN):
|
||||
index = i * NUM_LUMEN + j
|
||||
result = self.cal_read(index)
|
||||
if result is None:
|
||||
if verbose:
|
||||
print(f" Failed to read index {index}")
|
||||
return None
|
||||
row.append(result[1])
|
||||
if valid is None:
|
||||
valid = result[2]
|
||||
table.append(row)
|
||||
|
||||
if verbose:
|
||||
status = "calibrated" if valid else "defaults"
|
||||
print(f" Read complete ({status})")
|
||||
return table, valid
|
||||
|
||||
def set_test_point(self, cct, lumen, verbose=False):
|
||||
"""Set the light to a specific CCT and lumen test point."""
|
||||
if verbose:
|
||||
print(f" Setting CCT={cct}K, Lumen={lumen}")
|
||||
|
||||
if not self.set_color(cct):
|
||||
if verbose:
|
||||
print(" Failed to set color")
|
||||
return False
|
||||
time.sleep(0.2)
|
||||
|
||||
if not self.set_intensity(lumen):
|
||||
if verbose:
|
||||
print(" Failed to set intensity")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def take_measurement(self, timeout=30, verbose=False):
|
||||
"""
|
||||
Call spectrometer script to take a measurement.
|
||||
|
||||
Expected JSON output: {"lumen": 450.5, "cct": 3250}
|
||||
"""
|
||||
if verbose:
|
||||
print(f" Taking measurement...")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['uv', 'run', 'python', str(self.spectrometer_script), '--measure'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout
|
||||
)
|
||||
if result.returncode != 0:
|
||||
if verbose:
|
||||
print(f" Spectrometer error: {result.stderr.strip()}")
|
||||
return None
|
||||
data = json.loads(result.stdout)
|
||||
if 'lumen' in data and 'cct' in data:
|
||||
return data
|
||||
if verbose:
|
||||
print(f" Invalid response: missing lumen or cct")
|
||||
return None
|
||||
except subprocess.TimeoutExpired:
|
||||
if verbose:
|
||||
print(f" Timeout waiting for spectrometer")
|
||||
return None
|
||||
except json.JSONDecodeError as e:
|
||||
if verbose:
|
||||
print(f" Invalid JSON from spectrometer: {e}")
|
||||
return None
|
||||
except FileNotFoundError:
|
||||
if verbose:
|
||||
print(f" Spectrometer script not found: {self.spectrometer_script}")
|
||||
return None
|
||||
|
||||
def measure_all_points(self):
|
||||
"""Measure all calibration points once. Returns True if all succeeded.
|
||||
|
||||
Points are ordered to minimize drive current changes between consecutive
|
||||
measurements: sorted by lumen (ascending), with CCT in snake pattern
|
||||
(alternating direction) to avoid large jumps.
|
||||
"""
|
||||
total_points = len(CCT_POINTS) * len(LUMEN_POINTS)
|
||||
current_point = 0
|
||||
success_count = 0
|
||||
|
||||
# Generate measurement order: snake pattern to minimize current jumps
|
||||
# At each lumen level, alternate CCT direction
|
||||
measurement_order = []
|
||||
for i, lumen in enumerate(LUMEN_POINTS):
|
||||
ccts = CCT_POINTS if i % 2 == 0 else list(reversed(CCT_POINTS))
|
||||
for cct in ccts:
|
||||
measurement_order.append((cct, lumen))
|
||||
|
||||
for cct, lumen in measurement_order:
|
||||
current_point += 1
|
||||
key = (cct, lumen)
|
||||
|
||||
print(f" [{current_point}/{total_points}] CCT={cct}K, Lumen={lumen}", end="")
|
||||
|
||||
# Set test point
|
||||
if not self.set_test_point(cct, lumen):
|
||||
print(" - FAILED to set")
|
||||
continue
|
||||
|
||||
# Wait for light to stabilize
|
||||
time.sleep(4.0)
|
||||
|
||||
# Take measurement with plausibility check
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
measurement = self.take_measurement()
|
||||
if not measurement:
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(2.0)
|
||||
continue
|
||||
print(" - NO READING")
|
||||
break
|
||||
|
||||
# Plausibility check: deviation must be < 60%
|
||||
cct_dev = abs(measurement['cct'] - cct) / cct
|
||||
lumen_dev = abs(measurement['lumen'] - lumen) / lumen if lumen > 0 else 0
|
||||
|
||||
if cct_dev > 0.6 or lumen_dev > 0.6:
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(4.0)
|
||||
continue
|
||||
print(f" - IMPLAUSIBLE ({cct_dev*100:.0f}%/{lumen_dev*100:.0f}%)")
|
||||
break
|
||||
|
||||
# Measurement is plausible, store it
|
||||
self.measurements[key] = {
|
||||
'target_cct': cct,
|
||||
'target_lumen': lumen,
|
||||
'measured_cct': measurement['cct'],
|
||||
'measured_lumen': measurement['lumen']
|
||||
}
|
||||
cct_err = (measurement['cct'] - cct) / cct * 100
|
||||
lumen_err = (measurement['lumen'] - lumen) / lumen * 100
|
||||
print(f" -> CCT:{cct_err:+5.1f}% Lum:{lumen_err:+5.1f}%")
|
||||
success_count += 1
|
||||
break
|
||||
|
||||
return success_count == total_points
|
||||
|
||||
def print_deviation_table(self):
|
||||
"""Print a table showing deviation of all measured points."""
|
||||
print("\n" + "=" * 70)
|
||||
print("DEVIATION TABLE (% error from target)")
|
||||
print("=" * 70)
|
||||
|
||||
# Header
|
||||
header = " |"
|
||||
for lumen in LUMEN_POINTS:
|
||||
header += f" {lumen:4d}lm |"
|
||||
print(header)
|
||||
print("-" * len(header))
|
||||
|
||||
max_cct_dev = 0
|
||||
max_lumen_dev = 0
|
||||
all_within_tolerance = True
|
||||
cct_devs = []
|
||||
lumen_devs = []
|
||||
|
||||
for cct in CCT_POINTS:
|
||||
row = f"{cct:5d}K |"
|
||||
for lumen in LUMEN_POINTS:
|
||||
key = (cct, lumen)
|
||||
if key in self.measurements:
|
||||
m = self.measurements[key]
|
||||
cct_dev = (m['measured_cct'] - cct) / cct * 100
|
||||
lumen_dev = (m['measured_lumen'] - lumen) / lumen * 100
|
||||
cct_devs.append(cct_dev)
|
||||
lumen_devs.append(lumen_dev)
|
||||
max_cct_dev = max(max_cct_dev, abs(cct_dev))
|
||||
max_lumen_dev = max(max_lumen_dev, abs(lumen_dev))
|
||||
if abs(cct_dev) > 5 or abs(lumen_dev) > 5:
|
||||
all_within_tolerance = False
|
||||
row += f"{cct_dev:+4.1f}/{lumen_dev:+4.1f}|"
|
||||
else:
|
||||
row += " --- |"
|
||||
all_within_tolerance = False
|
||||
print(row)
|
||||
|
||||
# Calculate standard deviation
|
||||
def calc_std(values):
|
||||
if len(values) < 2:
|
||||
return 0.0
|
||||
mean = sum(values) / len(values)
|
||||
variance = sum((x - mean) ** 2 for x in values) / len(values)
|
||||
return variance ** 0.5
|
||||
|
||||
cct_std = calc_std(cct_devs)
|
||||
lumen_std = calc_std(lumen_devs)
|
||||
|
||||
print("-" * len(header))
|
||||
print(f"Format: CCT%/Lumen% | Max CCT: {max_cct_dev:.1f}% | Max Lumen: {max_lumen_dev:.1f}%")
|
||||
print(f"Standard deviation | CCT: {cct_std:.2f}% | Lumen: {lumen_std:.2f}%")
|
||||
|
||||
return all_within_tolerance, max_cct_dev, max_lumen_dev
|
||||
|
||||
def calibrate_y_data(self):
|
||||
"""Calibrate y-data tables by measuring lumen output at fixed drive points.
|
||||
|
||||
Returns (y27_cal, y50_cal) or (None, None) on failure.
|
||||
"""
|
||||
print("\n" + "=" * 70)
|
||||
print("Y-DATA CALIBRATION")
|
||||
print("=" * 70)
|
||||
print("Measuring lumen output at fixed drive currents")
|
||||
print(f"Drive points (mA): {X_DATA}")
|
||||
print("=" * 70)
|
||||
|
||||
# First, clear any existing calibration to use defaults
|
||||
print("\nClearing existing calibration to use default tables...")
|
||||
time.sleep(0.2) # Brief delay to ensure UART is idle
|
||||
self.ser.reset_input_buffer() # Clear any pending diagnostics
|
||||
if not self.cal_clear():
|
||||
print("Failed to clear calibration")
|
||||
return None, None
|
||||
time.sleep(1.0)
|
||||
|
||||
# Calibrate 2700K LED (use 2600K CCT = pure warm LED, mixing ratio 0)
|
||||
print("\n--- Calibrating 2700K LED (pure warm) ---")
|
||||
y27_cal = self._measure_led_curve(2600, Y27_DEFAULT)
|
||||
if y27_cal is None:
|
||||
return None, None
|
||||
|
||||
# Turn off LEDs and wait for thermal stabilization before switching
|
||||
print("\nTurning off LEDs, waiting 10s for thermal stabilization...")
|
||||
self.set_intensity(0)
|
||||
time.sleep(10.0)
|
||||
|
||||
# Calibrate 5000K LED (use 5000K CCT = pure cool LED, mixing ratio 255)
|
||||
print("\n--- Calibrating 5000K LED (pure cool) ---")
|
||||
y50_cal = self._measure_led_curve(5000, Y50_DEFAULT)
|
||||
if y50_cal is None:
|
||||
return None, None
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("Y-DATA CALIBRATION RESULTS")
|
||||
print("=" * 70)
|
||||
print(f" mA points: {X_DATA}")
|
||||
print(f" y27 (2700K): {y27_cal} (default: {Y27_DEFAULT})")
|
||||
print(f" y50 (5000K): {y50_cal} (default: {Y50_DEFAULT})")
|
||||
|
||||
return y27_cal, y50_cal
|
||||
|
||||
def _measure_led_curve(self, cct, default_lumens):
|
||||
"""Measure lumen output at each drive point for a single LED type.
|
||||
|
||||
Args:
|
||||
cct: Color temperature to set (2600 for pure 2700K LED, 5000 for pure 5000K LED)
|
||||
default_lumens: Default y-data values to use as intensity targets
|
||||
|
||||
Returns:
|
||||
List of 5 measured lumen values, or None on failure
|
||||
"""
|
||||
measured = []
|
||||
|
||||
# Set color (this determines which LED is primarily driven)
|
||||
if not self.set_color(cct):
|
||||
print(f" Failed to set color to {cct}K")
|
||||
return None
|
||||
time.sleep(0.5)
|
||||
|
||||
for i, target_lumen in enumerate(default_lumens):
|
||||
ma = X_DATA[i]
|
||||
print(f" Point {i+1}/5: {ma:3d}mA (intensity={target_lumen:3d})", end="")
|
||||
|
||||
if ma == 0:
|
||||
# Zero point is always 0 lumen
|
||||
measured.append(0)
|
||||
print(" -> 0 lm (zero point)")
|
||||
continue
|
||||
|
||||
# Set intensity to the default lumen value
|
||||
# With cleared calibration (defaults), this drives at the target mA
|
||||
if not self.set_intensity(target_lumen):
|
||||
print(" - FAILED to set intensity")
|
||||
return None
|
||||
|
||||
# Wait for LED to stabilize (longer for last point at max current)
|
||||
stabilize_time = 5.0 if i == len(default_lumens) - 1 else 2.0
|
||||
time.sleep(stabilize_time)
|
||||
|
||||
# Take measurement with retry
|
||||
reading = None
|
||||
for attempt in range(3):
|
||||
reading = self.take_measurement()
|
||||
if reading and reading['lumen'] > 0:
|
||||
break
|
||||
time.sleep(1.0)
|
||||
|
||||
if reading is None:
|
||||
print(" - NO READING")
|
||||
return None
|
||||
|
||||
actual_lumen = int(round(reading['lumen']))
|
||||
deviation = (actual_lumen - target_lumen) / target_lumen * 100 if target_lumen > 0 else 0
|
||||
measured.append(actual_lumen)
|
||||
print(f" -> {actual_lumen:4d} lm ({deviation:+5.1f}%)")
|
||||
|
||||
return measured
|
||||
|
||||
def run_calibration(self, skip_y=False):
|
||||
"""Run full calibration: y-data first, then iterative mixing ratios.
|
||||
|
||||
Args:
|
||||
skip_y: If True, skip y-data calibration (only do mixing ratios)
|
||||
"""
|
||||
print("=" * 70)
|
||||
print("LED Light Full Calibration")
|
||||
print("=" * 70)
|
||||
print("Phase 1: Y-data calibration (LED lumen curves)")
|
||||
print("Phase 2: Mixing ratio calibration (CCT/lumen targeting)")
|
||||
print("=" * 70)
|
||||
|
||||
self.connect()
|
||||
y27_cal = None
|
||||
y50_cal = None
|
||||
|
||||
try:
|
||||
# Disable profile mode
|
||||
print("\nDisabling profile mode...")
|
||||
self.disable_profile()
|
||||
time.sleep(0.5)
|
||||
|
||||
# Phase 1: Y-data calibration
|
||||
if not skip_y:
|
||||
y27_cal, y50_cal = self.calibrate_y_data()
|
||||
if y27_cal is None:
|
||||
print("\nY-data calibration failed, aborting.")
|
||||
return
|
||||
# Write y-data immediately with default mixing ratios
|
||||
print("\nWriting y-data to device...")
|
||||
default_mixing = [
|
||||
[ 0, 0, 0, 0, 0],
|
||||
[ 32, 38, 36, 37, 36],
|
||||
[ 84, 72, 70, 71, 71],
|
||||
[142, 100, 99, 99, 99],
|
||||
[175, 131, 126, 127, 126],
|
||||
[155, 154, 154, 157, 153],
|
||||
[206, 170, 184, 184, 185],
|
||||
[242, 223, 208, 216, 217],
|
||||
[255, 255, 255, 255, 255]
|
||||
]
|
||||
if not self.write_calibration_table(default_mixing, y27_cal, y50_cal, verbose=False):
|
||||
print("Failed to write y-data, aborting.")
|
||||
return
|
||||
print("Y-data written, proceeding to mixing ratio calibration...")
|
||||
time.sleep(1.0)
|
||||
|
||||
# Phase 2: Mixing ratio calibration
|
||||
print("\n" + "=" * 70)
|
||||
print("MIXING RATIO CALIBRATION")
|
||||
print("=" * 70)
|
||||
print("Target: < 5% deviation on all points")
|
||||
print("Maximum passes: 10")
|
||||
|
||||
max_passes = 10
|
||||
for pass_num in range(1, max_passes + 1):
|
||||
print(f"\n{'='*70}")
|
||||
print(f"PASS {pass_num}/{max_passes}")
|
||||
print("=" * 70)
|
||||
|
||||
# Clear previous measurements for fresh pass
|
||||
self.measurements = {}
|
||||
|
||||
# Measure all points
|
||||
print("\nMeasuring all points...")
|
||||
self.measure_all_points()
|
||||
|
||||
# Print deviation table
|
||||
converged, max_cct, max_lumen = self.print_deviation_table()
|
||||
|
||||
# Save progress
|
||||
self.save_calibration()
|
||||
|
||||
if converged:
|
||||
print(f"\n*** CONVERGED after {pass_num} pass(es)! ***")
|
||||
print("All points within 5% tolerance.")
|
||||
break
|
||||
|
||||
if pass_num == max_passes:
|
||||
print(f"\n*** MAX PASSES REACHED ***")
|
||||
print(f"Best achieved: CCT {max_cct:.1f}%, Lumen {max_lumen:.1f}%")
|
||||
break
|
||||
|
||||
# Generate and apply corrections
|
||||
print("\nCalculating and applying corrections...")
|
||||
calibrated_table = self.generate_table(verbose=False)
|
||||
|
||||
if calibrated_table:
|
||||
if self.write_calibration_table(calibrated_table, y27_cal, y50_cal, verbose=False):
|
||||
print("Corrections applied, starting next pass...")
|
||||
time.sleep(1.0)
|
||||
else:
|
||||
print("Failed to write calibration, stopping.")
|
||||
break
|
||||
|
||||
finally:
|
||||
self.disconnect()
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("Calibration complete!")
|
||||
print("=" * 70)
|
||||
|
||||
def save_calibration(self):
|
||||
"""Save current calibration state to file."""
|
||||
data = {
|
||||
'measurements': {
|
||||
f"{k[0]},{k[1]}": v for k, v in self.measurements.items()
|
||||
},
|
||||
'cct_points': CCT_POINTS,
|
||||
'lumen_points': LUMEN_POINTS
|
||||
}
|
||||
with open(CALIBRATION_OUTPUT, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
def generate_table(self, verbose=False):
|
||||
"""
|
||||
Generate the calibrated mixing ratio table using direct proportional correction.
|
||||
|
||||
Simple approach: mixing ratio controls CCT.
|
||||
- If measured CCT < target CCT: increase ratio (more 5000K LED)
|
||||
- If measured CCT > target CCT: decrease ratio (less 5000K LED)
|
||||
- Correction is proportional to error, scaled by CCT-to-ratio gradient
|
||||
"""
|
||||
if verbose:
|
||||
print("\nGenerating calibration table...")
|
||||
|
||||
# Read current mixing ratios from device (baseline)
|
||||
baseline_table = None
|
||||
if self.ser and self.ser.is_open:
|
||||
result = self.read_calibration_table(verbose=verbose)
|
||||
if result:
|
||||
baseline_table, _ = result
|
||||
if verbose:
|
||||
print("Using current device values as baseline")
|
||||
|
||||
if baseline_table is None:
|
||||
if verbose:
|
||||
print("Using firmware defaults as baseline")
|
||||
baseline_table = [
|
||||
[ 0, 0, 0, 0, 0], # 2600K
|
||||
[ 32, 38, 36, 37, 36], # 2900K
|
||||
[ 84, 72, 70, 71, 71], # 3200K
|
||||
[142, 100, 99, 99, 99], # 3500K
|
||||
[175, 131, 126, 127, 126], # 3800K
|
||||
[155, 154, 154, 157, 153], # 4100K
|
||||
[206, 170, 184, 184, 185], # 4400K
|
||||
[242, 223, 208, 216, 217], # 4700K
|
||||
[255, 255, 255, 255, 255] # 5000K
|
||||
]
|
||||
|
||||
# CCT range: 2600K to 5000K = 2400K maps to ratio 0-255
|
||||
# Gradient: ~9.4 K per ratio unit (but use 10 for simpler math)
|
||||
K_PER_RATIO = 10.0
|
||||
|
||||
corrections = {}
|
||||
for (cct, lumen), m in self.measurements.items():
|
||||
i = CCT_POINTS.index(cct)
|
||||
j = LUMEN_POINTS.index(lumen)
|
||||
baseline_r = baseline_table[i][j]
|
||||
|
||||
target_cct = m['target_cct']
|
||||
target_lumen = m['target_lumen']
|
||||
measured_cct = m['measured_cct']
|
||||
measured_lumen = m['measured_lumen']
|
||||
|
||||
cct_error = target_cct - measured_cct # positive = need higher CCT = more 5000K = higher ratio
|
||||
lumen_error = target_lumen - measured_lumen
|
||||
|
||||
# Simple proportional correction: dr = cct_error / K_PER_RATIO
|
||||
# If measured CCT is 100K too low, increase ratio by 10 units
|
||||
dr = cct_error / K_PER_RATIO
|
||||
|
||||
# Apply correction with gain factor (can be tuned)
|
||||
# Use 1.0 for full correction, <1 for damping, >1 for aggressive
|
||||
gain = 1.0
|
||||
dr = dr * gain
|
||||
|
||||
# Limit adjustment per iteration to prevent oscillation
|
||||
dr = max(-30, min(30, dr))
|
||||
|
||||
corrections[(cct, lumen)] = {
|
||||
'cct_error': cct_error,
|
||||
'lumen_error': lumen_error,
|
||||
'lumen_error_pct': 100 * lumen_error / target_lumen if target_lumen > 0 else 0,
|
||||
'mixing_adjust': dr,
|
||||
'baseline_ratio': baseline_r
|
||||
}
|
||||
|
||||
# Apply corrections to generate calibrated table
|
||||
calibrated_table = []
|
||||
for i, cct in enumerate(CCT_POINTS):
|
||||
row = []
|
||||
for j, lumen in enumerate(LUMEN_POINTS):
|
||||
baseline_r = baseline_table[i][j]
|
||||
key = (cct, lumen)
|
||||
if key in corrections:
|
||||
c = corrections[key]
|
||||
adjusted = baseline_r + c['mixing_adjust']
|
||||
adjusted = max(0, min(255, round(adjusted)))
|
||||
else:
|
||||
adjusted = baseline_r
|
||||
row.append(int(adjusted))
|
||||
calibrated_table.append(row)
|
||||
|
||||
# Print correction details
|
||||
if verbose:
|
||||
print("\nCorrection summary:")
|
||||
print(f"{'CCT':>5} {'Lumen':>5} | {'CCT err':>8} {'Lum err':>8} | {'Base':>4} {'Adj':>6} {'New':>4}")
|
||||
print("-" * 65)
|
||||
for i, cct in enumerate(CCT_POINTS):
|
||||
for j, lumen in enumerate(LUMEN_POINTS):
|
||||
key = (cct, lumen)
|
||||
if key in corrections:
|
||||
c = corrections[key]
|
||||
new_r = calibrated_table[i][j]
|
||||
print(f"{cct:>5} {lumen:>5} | {c['cct_error']:>+7.0f}K {c['lumen_error']:>+7.0f}lm | {c['baseline_ratio']:>4} {c['mixing_adjust']:>+6.1f} {new_r:>4}")
|
||||
|
||||
# Print final table
|
||||
print("\nCalibrated mixing_ratios table:")
|
||||
print("const uint8_t mixing_ratios[NUM_CCT][NUM_LUMEN] = {")
|
||||
for i, (cct, row) in enumerate(zip(CCT_POINTS, calibrated_table)):
|
||||
values = ", ".join(f"{v:3d}" for v in row)
|
||||
comment = f"// {cct}K"
|
||||
if i == 0:
|
||||
comment += " (pure 2700K)"
|
||||
elif i == len(CCT_POINTS) - 1:
|
||||
comment += " (pure 5000K)"
|
||||
comma = "," if i < len(CCT_POINTS) - 1 else ""
|
||||
print(f" {{{values}}}{comma} {comment}")
|
||||
print("};")
|
||||
|
||||
# Save to calibration file
|
||||
data = {
|
||||
'measurements': {f"{k[0]},{k[1]}": v for k, v in self.measurements.items()},
|
||||
'corrections': {f"{k[0]},{k[1]}": v for k, v in corrections.items()},
|
||||
'cct_points': CCT_POINTS,
|
||||
'lumen_points': LUMEN_POINTS,
|
||||
'baseline_table': baseline_table,
|
||||
'calibrated_table': calibrated_table
|
||||
}
|
||||
with open(CALIBRATION_OUTPUT, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
if verbose:
|
||||
print(f"\nCalibration saved to {CALIBRATION_OUTPUT}")
|
||||
|
||||
return calibrated_table
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='LED Light Calibration Tool')
|
||||
parser.add_argument('port', nargs='?', default='COM3',
|
||||
help='Serial port (default: COM3)')
|
||||
parser.add_argument('--baudrate', '-b', type=int, default=115200,
|
||||
help='Baud rate (default: 115200)')
|
||||
parser.add_argument('--spectrometer', '-s', type=Path,
|
||||
default=DEFAULT_SPECTROMETER,
|
||||
help='Path to spectrometer script (default: spectrometer.py)')
|
||||
parser.add_argument('--generate-only', '-g', action='store_true',
|
||||
help='Only generate table from existing measurements')
|
||||
parser.add_argument('--write', '-w', action='store_true',
|
||||
help='Write calibration table to device')
|
||||
parser.add_argument('--clear', '-c', action='store_true',
|
||||
help='Clear calibration on device (revert to defaults)')
|
||||
parser.add_argument('--read', action='store_true',
|
||||
help='Read calibration table from device')
|
||||
parser.add_argument('--skip-y', action='store_true',
|
||||
help='Skip y-data calibration (only do mixing ratios)')
|
||||
parser.add_argument('--y-only', action='store_true',
|
||||
help='Only run y-data calibration (no mixing ratios)')
|
||||
args = parser.parse_args()
|
||||
|
||||
tool = CalibrationTool(args.port, args.baudrate, args.spectrometer)
|
||||
|
||||
if args.read:
|
||||
# Read calibration from device
|
||||
tool.connect()
|
||||
try:
|
||||
result = tool.read_calibration_table()
|
||||
if result:
|
||||
table, valid = result
|
||||
status = "CALIBRATED" if valid else "DEFAULTS"
|
||||
print(f"\nCalibration status: {status}")
|
||||
print("\nMixing ratios table:")
|
||||
for i, (cct, row) in enumerate(zip(CCT_POINTS, table)):
|
||||
values = ", ".join(f"{v:3d}" for v in row)
|
||||
print(f" {cct}K: [{values}]")
|
||||
finally:
|
||||
tool.disconnect()
|
||||
elif args.clear:
|
||||
# Clear calibration on device
|
||||
tool.connect()
|
||||
try:
|
||||
if tool.cal_clear():
|
||||
print("Calibration cleared successfully")
|
||||
else:
|
||||
print("Failed to clear calibration")
|
||||
finally:
|
||||
tool.disconnect()
|
||||
elif args.y_only:
|
||||
# Only run y-data calibration
|
||||
tool.connect()
|
||||
try:
|
||||
tool.disable_profile()
|
||||
time.sleep(0.5)
|
||||
y27_cal, y50_cal = tool.calibrate_y_data()
|
||||
if y27_cal and y50_cal:
|
||||
# Write y-data with default mixing ratios
|
||||
print("\nWriting y-data calibration (with default mixing ratios)...")
|
||||
default_mixing = [
|
||||
[ 0, 0, 0, 0, 0], # 2600K
|
||||
[ 32, 38, 36, 37, 36], # 2900K
|
||||
[ 84, 72, 70, 71, 71], # 3200K
|
||||
[142, 100, 99, 99, 99], # 3500K
|
||||
[175, 131, 126, 127, 126], # 3800K
|
||||
[155, 154, 154, 157, 153], # 4100K
|
||||
[206, 170, 184, 184, 185], # 4400K
|
||||
[242, 223, 208, 216, 217], # 4700K
|
||||
[255, 255, 255, 255, 255] # 5000K
|
||||
]
|
||||
if tool.write_calibration_table(default_mixing, y27_cal, y50_cal):
|
||||
print("Y-data calibration saved successfully!")
|
||||
# Save to file as well
|
||||
data = {
|
||||
'y27_cal': y27_cal,
|
||||
'y50_cal': y50_cal,
|
||||
'x_data': X_DATA
|
||||
}
|
||||
with open(CALIBRATION_OUTPUT, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
else:
|
||||
print("Failed to write y-data calibration")
|
||||
finally:
|
||||
tool.disconnect()
|
||||
elif args.generate_only or args.write:
|
||||
# Load existing measurements and generate/write table
|
||||
if CALIBRATION_OUTPUT.exists():
|
||||
with open(CALIBRATION_OUTPUT, 'r') as f:
|
||||
saved = json.load(f)
|
||||
if 'measurements' in saved:
|
||||
tool.measurements = {
|
||||
tuple(map(int, k.split(','))): v
|
||||
for k, v in saved['measurements'].items()
|
||||
}
|
||||
|
||||
# Connect to read current values from device
|
||||
tool.connect()
|
||||
try:
|
||||
table = tool.generate_table(verbose=True)
|
||||
|
||||
if args.write and table:
|
||||
# Load y-data if available
|
||||
y27 = saved.get('y27_cal')
|
||||
y50 = saved.get('y50_cal')
|
||||
tool.write_calibration_table(table, y27, y50)
|
||||
finally:
|
||||
tool.disconnect()
|
||||
else:
|
||||
print(f"No calibration file found: {CALIBRATION_OUTPUT}")
|
||||
else:
|
||||
tool.run_calibration(skip_y=args.skip_y)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
-887
@@ -1,887 +0,0 @@
|
||||
"""
|
||||
HPCS 6500 Spectrophotometer / Integrating Sphere — Direct Reader
|
||||
|
||||
Communicates with the HPCS 6500 over USB serial (STM32 VCP) to take
|
||||
measurements and extract photometric, colorimetric, radiometric,
|
||||
electrical, and spectral data.
|
||||
|
||||
Usage:
|
||||
uv run hpcs6500.py # auto-detect port, single reading
|
||||
uv run hpcs6500.py --port COM42 # specify port
|
||||
uv run hpcs6500.py --continuous # continuous readings
|
||||
uv run hpcs6500.py --csv out.csv # save to CSV
|
||||
uv run hpcs6500.py --spectrum # also print full spectrum
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import serial
|
||||
import serial.tools.list_ports
|
||||
|
||||
# Protocol constants
|
||||
SYNC = 0x8C
|
||||
CMD_IDENTIFY = 0x00
|
||||
CMD_INTEGRATION_TIME = 0x01
|
||||
CMD_POLL_STATE = 0x03
|
||||
CMD_STATUS = 0x05
|
||||
CMD_START = 0x0E
|
||||
CMD_READ_MEASUREMENT = 0x13
|
||||
CMD_RESET = 0x25
|
||||
CMD_CONFIG = 0x2A
|
||||
CMD_SET_DC = 0x73
|
||||
CMD_READ_ELECTRICAL = 0x77
|
||||
CMD_SET_AC = 0x78
|
||||
CMD_READ_PSU = 0x79
|
||||
CMD_PSU_OUTPUT = 0x72
|
||||
CMD_SET_MODE = 0x7A
|
||||
CMD_TEST_CONFIG = 0x2B
|
||||
|
||||
# Single-test config payloads captured from vendor software.
|
||||
# Sub 00: test parameters, sub 01: test config with auto-PSU flag at byte 37.
|
||||
_TEST_CONFIG_SUB0 = bytes.fromhex(
|
||||
"00003c00000001008813037c011a04000000484200000208"
|
||||
"000000000001060000000000803f0000803f0000803f0000"
|
||||
"803f0000803f00000000003f"
|
||||
)
|
||||
_TEST_CONFIG_SUB1_BASE = bytes.fromhex(
|
||||
"017c010c037c010c037c010c030000803f000000000000000000000000"
|
||||
"00000000000000000001000000400000803f"
|
||||
)
|
||||
|
||||
# State polling byte[5] values
|
||||
STATE_IDLE = 0x04
|
||||
STATE_MEASURING = 0x01
|
||||
|
||||
# Spectrum parameters
|
||||
SPECTRUM_START_NM = 380
|
||||
SPECTRUM_END_NM = 1050
|
||||
SPECTRUM_OFFSET = 432
|
||||
SPECTRUM_COUNT = 350
|
||||
SPECTRUM_STEP = (SPECTRUM_END_NM - SPECTRUM_START_NM) / (SPECTRUM_COUNT - 1)
|
||||
|
||||
# Measurement block field offsets (all float32 LE)
|
||||
FIELDS = {
|
||||
"Phi_lm": 36,
|
||||
"eta_lm_W": 40,
|
||||
"CCT_K": 44,
|
||||
"Duv": 48,
|
||||
"x": 52,
|
||||
"y": 56,
|
||||
"u": 60,
|
||||
"v": 64,
|
||||
"u_prime": 68,
|
||||
"v_prime": 72,
|
||||
"SDCM": 76,
|
||||
"Ra": 80,
|
||||
"R1": 84,
|
||||
"R2": 88,
|
||||
"R3": 92,
|
||||
"R4": 96,
|
||||
"R5": 100,
|
||||
"R6": 104,
|
||||
"R7": 108,
|
||||
"R8": 112,
|
||||
"R9": 116,
|
||||
"R10": 120,
|
||||
"R11": 124,
|
||||
"R12": 128,
|
||||
"R13": 132,
|
||||
"R14": 136,
|
||||
"R15": 140,
|
||||
"Phi_e_mW": 144,
|
||||
"Phi_euv_mW": 148,
|
||||
"Phi_eb_mW": 152,
|
||||
"Phi_ey_mW": 156,
|
||||
"Phi_er_mW": 160,
|
||||
"Phi_efr_mW": 164,
|
||||
"Phi_eir_mW": 168,
|
||||
"Phi_e_total": 172,
|
||||
"CIE_X": 224,
|
||||
"CIE_Y": 228,
|
||||
"CIE_Z": 232,
|
||||
"TLCI": 236,
|
||||
"PeakSignal": 244,
|
||||
"DarkSignal": 248,
|
||||
"Compensate": 252,
|
||||
}
|
||||
|
||||
# Electrical block field offsets
|
||||
ELECTRICAL_FIELDS = {
|
||||
"Voltage_V": 8,
|
||||
"Current_A": 12,
|
||||
"Power_W": 16,
|
||||
"Freq_Hz": 20,
|
||||
"PF": 24,
|
||||
}
|
||||
|
||||
# Harmonics offsets within the electrical block
|
||||
HARMONICS_V_OFFSET = 544 # 50 x float32 voltage harmonics (%)
|
||||
HARMONICS_V_COUNT = 50
|
||||
UTHD_OFFSET = 744 # float32 UThd (%)
|
||||
HARMONICS_I_OFFSET = 800 # 50 x float32 current harmonics (%)
|
||||
HARMONICS_I_COUNT = 50
|
||||
ATHD_OFFSET = 1000 # float32 AThd (%)
|
||||
|
||||
# Waveform offsets (int16 LE samples, 128 per waveform)
|
||||
WAVEFORM_V_OFFSET = 30
|
||||
WAVEFORM_I_OFFSET = 286
|
||||
WAVEFORM_SAMPLES = 128
|
||||
|
||||
|
||||
def parse_pcap_messages(filepath):
|
||||
"""Parse a USBPcap pcap file and extract bulk transfer payloads."""
|
||||
with open(filepath, "rb") as f:
|
||||
data = f.read()
|
||||
magic = struct.unpack_from("<I", data, 0)[0]
|
||||
endian = "<" if magic == 0xA1B2C3D4 else ">"
|
||||
offset = 24
|
||||
messages = []
|
||||
first_ts = None
|
||||
while offset + 16 <= len(data):
|
||||
ts_sec, ts_usec, incl_len, orig_len = struct.unpack_from(
|
||||
f"{endian}IIII", data, offset
|
||||
)
|
||||
offset += 16
|
||||
if offset + incl_len > len(data):
|
||||
break
|
||||
pkt_data = data[offset : offset + incl_len]
|
||||
offset += incl_len
|
||||
if len(pkt_data) < 27:
|
||||
continue
|
||||
hdr_len = struct.unpack_from("<H", pkt_data, 0)[0]
|
||||
endpoint = pkt_data[21]
|
||||
transfer = pkt_data[22]
|
||||
payload = pkt_data[hdr_len:]
|
||||
if len(payload) == 0 or transfer != 3:
|
||||
continue
|
||||
ts = ts_sec + ts_usec / 1_000_000
|
||||
if first_ts is None:
|
||||
first_ts = ts
|
||||
ep_dir = "IN" if (endpoint & 0x80) else "OUT"
|
||||
messages.append(
|
||||
{
|
||||
"ts": ts - first_ts,
|
||||
"dir": "TX" if ep_dir == "OUT" else "RX",
|
||||
"data": payload,
|
||||
}
|
||||
)
|
||||
return messages
|
||||
|
||||
|
||||
class HPCS6500:
|
||||
"""Driver for the HPCS 6500 spectrophotometer."""
|
||||
|
||||
def __init__(self, port, timeout=2.0):
|
||||
self.ser = serial.Serial(
|
||||
port=port,
|
||||
baudrate=115200,
|
||||
timeout=timeout,
|
||||
write_timeout=timeout,
|
||||
)
|
||||
self.port = port
|
||||
|
||||
def close(self):
|
||||
self.ser.close()
|
||||
|
||||
def _send(self, *cmd_bytes):
|
||||
"""Send a command to the device."""
|
||||
data = bytes([SYNC] + list(cmd_bytes))
|
||||
self.ser.write(data)
|
||||
|
||||
def _recv(self, expected_len, timeout=3.0):
|
||||
"""Read expected_len bytes from the device."""
|
||||
data = b""
|
||||
deadline = time.monotonic() + timeout
|
||||
while len(data) < expected_len:
|
||||
remaining = expected_len - len(data)
|
||||
chunk = self.ser.read(remaining)
|
||||
if chunk:
|
||||
data += chunk
|
||||
elif time.monotonic() > deadline:
|
||||
break
|
||||
return data
|
||||
|
||||
def _recv_response(self, cmd_id, timeout=3.0):
|
||||
"""Read a response, expecting it to start with 8C <cmd_id>."""
|
||||
# Read the 2-byte header first
|
||||
hdr = self._recv(2, timeout)
|
||||
if len(hdr) < 2:
|
||||
return None
|
||||
if hdr[0] != SYNC or hdr[1] != cmd_id:
|
||||
# Try to resync — read until we find 8C <cmd_id>
|
||||
buf = hdr
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
b = self.ser.read(1)
|
||||
if not b:
|
||||
continue
|
||||
buf += b
|
||||
idx = buf.find(bytes([SYNC, cmd_id]))
|
||||
if idx >= 0:
|
||||
hdr = buf[idx:idx+2]
|
||||
break
|
||||
else:
|
||||
return None
|
||||
return hdr
|
||||
|
||||
def identify(self):
|
||||
"""Send identify command, return device name string."""
|
||||
self.ser.reset_input_buffer()
|
||||
self._send(CMD_IDENTIFY)
|
||||
resp = self._recv(16, timeout=2.0)
|
||||
if len(resp) < 16:
|
||||
return None
|
||||
if resp[0] != SYNC or resp[1] != CMD_IDENTIFY:
|
||||
return None
|
||||
name = resp[2:10].decode("ascii", errors="replace").rstrip("\x00")
|
||||
return name
|
||||
|
||||
def read_measurement_block(self):
|
||||
"""Send 8C 13 and read the 3904-byte measurement block."""
|
||||
self.ser.reset_input_buffer()
|
||||
self._send(CMD_READ_MEASUREMENT)
|
||||
# First, read the 4-byte header: 8C 13 0F 40
|
||||
hdr = self._recv(4, timeout=3.0)
|
||||
if len(hdr) < 4 or hdr[0] != SYNC or hdr[1] != CMD_READ_MEASUREMENT:
|
||||
return None
|
||||
# Then read the 3904-byte payload
|
||||
payload = self._recv(3904, timeout=5.0)
|
||||
if len(payload) < 3904:
|
||||
print(f"Warning: measurement block short ({len(payload)}/3904 bytes)")
|
||||
return None
|
||||
return payload
|
||||
|
||||
def read_electrical_block(self):
|
||||
"""Send 8C 77 and read the 1584-byte electrical block."""
|
||||
self.ser.reset_input_buffer()
|
||||
self._send(CMD_READ_ELECTRICAL)
|
||||
# 4-byte header: 8C 77 06 30
|
||||
hdr = self._recv(4, timeout=3.0)
|
||||
if len(hdr) < 4 or hdr[0] != SYNC or hdr[1] != CMD_READ_ELECTRICAL:
|
||||
return None
|
||||
payload = self._recv(1584, timeout=5.0)
|
||||
if len(payload) < 1584:
|
||||
print(f"Warning: electrical block short ({len(payload)}/1584 bytes)")
|
||||
return None
|
||||
return payload
|
||||
|
||||
def poll_state(self):
|
||||
"""Send 8C 03 and return (data_available, state)."""
|
||||
self.ser.reset_input_buffer()
|
||||
self._send(CMD_POLL_STATE)
|
||||
resp = self._recv(9, timeout=2.0)
|
||||
if len(resp) < 9 or resp[0] != SYNC or resp[1] != CMD_POLL_STATE:
|
||||
return None, None
|
||||
data_available = resp[2] == 1
|
||||
state = resp[5]
|
||||
return data_available, state
|
||||
|
||||
def send_test_config(self, auto_psu=False):
|
||||
"""Send single-test configuration (8C 2B sub 00 + sub 01).
|
||||
If auto_psu=True, device handles PSU on/off automatically."""
|
||||
self.ser.reset_input_buffer()
|
||||
self.ser.write(bytes([SYNC, CMD_TEST_CONFIG]) + _TEST_CONFIG_SUB0)
|
||||
resp = self._recv(2, timeout=2.0)
|
||||
if resp != bytes([SYNC, CMD_TEST_CONFIG]):
|
||||
return False
|
||||
|
||||
sub1 = bytearray(_TEST_CONFIG_SUB1_BASE)
|
||||
if auto_psu:
|
||||
sub1[37] = 0x01
|
||||
self.ser.reset_input_buffer()
|
||||
self.ser.write(bytes([SYNC, CMD_TEST_CONFIG]) + bytes(sub1))
|
||||
resp = self._recv(2, timeout=2.0)
|
||||
return resp == bytes([SYNC, CMD_TEST_CONFIG])
|
||||
|
||||
def start_measurement(self):
|
||||
"""Send 8C 0E 01 to start measurement."""
|
||||
self.ser.reset_input_buffer()
|
||||
self._send(CMD_START, 0x01)
|
||||
resp = self._recv(2, timeout=2.0)
|
||||
return resp == bytes([SYNC, CMD_START])
|
||||
|
||||
def stop_measurement(self):
|
||||
"""Send 8C 0E 02 to stop measurement."""
|
||||
self.ser.reset_input_buffer()
|
||||
self._send(CMD_START, 0x02)
|
||||
resp = self._recv(2, timeout=2.0)
|
||||
return resp == bytes([SYNC, CMD_START])
|
||||
|
||||
def reset(self):
|
||||
"""Send 8C 25 to reset."""
|
||||
self.ser.reset_input_buffer()
|
||||
self._send(CMD_RESET)
|
||||
resp = self._recv(2, timeout=2.0)
|
||||
return resp == bytes([SYNC, CMD_RESET])
|
||||
|
||||
# --- Power supply control ---
|
||||
|
||||
def psu_on(self):
|
||||
"""Turn PSU output on."""
|
||||
self.ser.reset_input_buffer()
|
||||
self._send(CMD_PSU_OUTPUT, 0x00)
|
||||
resp = self._recv(2, timeout=2.0)
|
||||
return resp == bytes([SYNC, CMD_PSU_OUTPUT])
|
||||
|
||||
def psu_off(self):
|
||||
"""Turn PSU output off."""
|
||||
self.ser.reset_input_buffer()
|
||||
self._send(CMD_PSU_OUTPUT, 0x01)
|
||||
resp = self._recv(2, timeout=2.0)
|
||||
return resp == bytes([SYNC, CMD_PSU_OUTPUT])
|
||||
|
||||
def set_mode(self, mode):
|
||||
"""Set output mode: 'ac' or 'dc'."""
|
||||
mode_byte = 0x00 if mode.lower() == "ac" else 0x01
|
||||
self.ser.reset_input_buffer()
|
||||
self._send(CMD_SET_MODE, mode_byte)
|
||||
resp = self._recv(2, timeout=2.0)
|
||||
return resp == bytes([SYNC, CMD_SET_MODE])
|
||||
|
||||
def set_ac_voltage(self, volts):
|
||||
"""Set AC output voltage (100-240 V)."""
|
||||
self.ser.reset_input_buffer()
|
||||
data = bytes([SYNC, CMD_SET_AC, 0x00]) + struct.pack("<f", volts)
|
||||
self.ser.write(data)
|
||||
resp = self._recv(2, timeout=2.0)
|
||||
return resp == bytes([SYNC, CMD_SET_AC])
|
||||
|
||||
def set_ac_frequency(self, hz):
|
||||
"""Set AC output frequency (50 or 60 Hz)."""
|
||||
self.ser.reset_input_buffer()
|
||||
data = bytes([SYNC, CMD_SET_AC, 0x01]) + struct.pack("<f", hz)
|
||||
self.ser.write(data)
|
||||
resp = self._recv(2, timeout=2.0)
|
||||
return resp == bytes([SYNC, CMD_SET_AC])
|
||||
|
||||
def set_dc_voltage(self, volts):
|
||||
"""Set DC output voltage (1-60 V)."""
|
||||
self.ser.reset_input_buffer()
|
||||
data = bytes([SYNC, CMD_SET_DC, 0x01]) + struct.pack("<f", volts)
|
||||
self.ser.write(data)
|
||||
resp = self._recv(2, timeout=2.0)
|
||||
return resp == bytes([SYNC, CMD_SET_DC])
|
||||
|
||||
def set_dc_current(self, amps):
|
||||
"""Set DC current limit (0-5 A)."""
|
||||
self.ser.reset_input_buffer()
|
||||
data = bytes([SYNC, CMD_SET_DC, 0x00]) + struct.pack("<f", amps)
|
||||
self.ser.write(data)
|
||||
resp = self._recv(2, timeout=2.0)
|
||||
return resp == bytes([SYNC, CMD_SET_DC])
|
||||
|
||||
def set_integration_time(self, ms):
|
||||
"""Set integration time in milliseconds. Use 0 for auto."""
|
||||
us = int(ms * 1000)
|
||||
self.ser.reset_input_buffer()
|
||||
data = bytes([SYNC, CMD_INTEGRATION_TIME]) + struct.pack("<I", us)
|
||||
self.ser.write(data)
|
||||
resp = self._recv(2, timeout=2.0)
|
||||
return resp == bytes([SYNC, CMD_INTEGRATION_TIME])
|
||||
|
||||
def read_psu_settings(self):
|
||||
"""Read current power supply settings."""
|
||||
self.ser.reset_input_buffer()
|
||||
self._send(CMD_READ_PSU)
|
||||
resp = self._recv(20, timeout=2.0)
|
||||
if len(resp) < 20 or resp[0] != SYNC or resp[1] != CMD_READ_PSU:
|
||||
return None
|
||||
payload = resp[2:]
|
||||
return {
|
||||
"ac_voltage": struct.unpack_from("<f", payload, 0)[0],
|
||||
"ac_frequency": struct.unpack_from("<f", payload, 4)[0],
|
||||
"dc_voltage": struct.unpack_from("<f", payload, 8)[0],
|
||||
"dc_current": struct.unpack_from("<f", payload, 12)[0],
|
||||
"mode": "DC" if payload[16] == 1 else "AC",
|
||||
}
|
||||
|
||||
def wait_for_data(self, timeout=30.0):
|
||||
"""Poll until data is available or timeout."""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
data_avail, state = self.poll_state()
|
||||
if data_avail:
|
||||
return True
|
||||
time.sleep(0.1)
|
||||
return False
|
||||
|
||||
def take_reading(self, psu=True):
|
||||
"""Take a single measurement reading. Returns dict or None.
|
||||
If psu=True, turns PSU output on before measuring and off after."""
|
||||
if psu:
|
||||
self.psu_on()
|
||||
time.sleep(0.2)
|
||||
|
||||
# Start measurement
|
||||
if not self.start_measurement():
|
||||
print("Failed to start measurement")
|
||||
if psu:
|
||||
self.psu_off()
|
||||
return None
|
||||
|
||||
# Wait for data
|
||||
if not self.wait_for_data(timeout=30.0):
|
||||
print("Timeout waiting for measurement data")
|
||||
self.stop_measurement()
|
||||
if psu:
|
||||
self.psu_off()
|
||||
return None
|
||||
|
||||
# Read measurement block
|
||||
meas = self.read_measurement_block()
|
||||
elec = self.read_electrical_block()
|
||||
|
||||
if meas is None:
|
||||
print("Failed to read measurement block")
|
||||
self.stop_measurement()
|
||||
if psu:
|
||||
self.psu_off()
|
||||
return None
|
||||
|
||||
result = self._parse_measurement(meas)
|
||||
|
||||
if elec is not None:
|
||||
result.update(self._parse_electrical(elec))
|
||||
|
||||
# Stop measurement
|
||||
self.stop_measurement()
|
||||
|
||||
if psu:
|
||||
self.psu_off()
|
||||
|
||||
return result
|
||||
|
||||
def take_single_reading(self):
|
||||
"""Trigger a single measurement matching the vendor's single-test flow.
|
||||
Sends stop to trigger a one-shot reading, waits for data, reads it,
|
||||
then resets. The device should be in single-test mode (configured via
|
||||
vendor software UI or send_test_config())."""
|
||||
self.stop_measurement()
|
||||
|
||||
if not self.wait_for_data(timeout=30.0):
|
||||
print("Timeout waiting for measurement data")
|
||||
return None
|
||||
|
||||
meas = self.read_measurement_block()
|
||||
elec = self.read_electrical_block()
|
||||
|
||||
if meas is None:
|
||||
print("Failed to read measurement block")
|
||||
return None
|
||||
|
||||
result = self._parse_measurement(meas)
|
||||
if elec is not None:
|
||||
result.update(self._parse_electrical(elec))
|
||||
|
||||
self.reset()
|
||||
return result
|
||||
|
||||
def read_current(self):
|
||||
"""Read current data without starting/stopping measurement.
|
||||
Use this when the vendor software is controlling the instrument."""
|
||||
meas = self.read_measurement_block()
|
||||
elec = self.read_electrical_block()
|
||||
|
||||
if meas is None:
|
||||
return None
|
||||
|
||||
result = self._parse_measurement(meas)
|
||||
if elec is not None:
|
||||
result.update(self._parse_electrical(elec))
|
||||
return result
|
||||
|
||||
def _parse_measurement(self, data):
|
||||
"""Parse the 3904-byte measurement block into a dict."""
|
||||
result = {}
|
||||
|
||||
# Check header
|
||||
header = data[:10].decode("ascii", errors="replace").rstrip("\x00")
|
||||
result["device"] = header
|
||||
|
||||
# Extract all named fields
|
||||
for name, offset in FIELDS.items():
|
||||
if offset + 4 <= len(data):
|
||||
result[name] = struct.unpack_from("<f", data, offset)[0]
|
||||
|
||||
# Extract date/time strings
|
||||
date_off = 272
|
||||
time_off = 283
|
||||
if date_off + 10 <= len(data):
|
||||
date_str = data[date_off:date_off+10].decode("ascii", errors="replace").rstrip("\x00")
|
||||
result["test_date"] = date_str
|
||||
if time_off + 8 <= len(data):
|
||||
time_str = data[time_off:time_off+8].decode("ascii", errors="replace").rstrip("\x00")
|
||||
result["test_time"] = time_str
|
||||
|
||||
# Extract spectrum
|
||||
spectrum = []
|
||||
for i in range(SPECTRUM_COUNT):
|
||||
off = SPECTRUM_OFFSET + i * 4
|
||||
if off + 4 <= len(data):
|
||||
val = struct.unpack_from("<f", data, off)[0]
|
||||
spectrum.append(val)
|
||||
result["spectrum"] = spectrum
|
||||
result["spectrum_nm"] = [
|
||||
SPECTRUM_START_NM + i * SPECTRUM_STEP for i in range(len(spectrum))
|
||||
]
|
||||
|
||||
return result
|
||||
|
||||
def _parse_electrical(self, data):
|
||||
"""Parse the 1584-byte electrical block into a dict."""
|
||||
result = {}
|
||||
for name, offset in ELECTRICAL_FIELDS.items():
|
||||
if offset + 4 <= len(data):
|
||||
result[name] = struct.unpack_from("<f", data, offset)[0]
|
||||
|
||||
# Check if harmonics data is present (non-zero at harmonics offset)
|
||||
if len(data) >= ATHD_OFFSET + 4:
|
||||
h1_v = struct.unpack_from("<f", data, HARMONICS_V_OFFSET)[0]
|
||||
if abs(h1_v - 100.0) < 1.0: # H1 should be ~100%
|
||||
# Voltage harmonics
|
||||
v_harmonics = []
|
||||
for i in range(HARMONICS_V_COUNT):
|
||||
off = HARMONICS_V_OFFSET + i * 4
|
||||
v_harmonics.append(struct.unpack_from("<f", data, off)[0])
|
||||
result["V_harmonics"] = v_harmonics
|
||||
result["UThd"] = struct.unpack_from("<f", data, UTHD_OFFSET)[0]
|
||||
|
||||
# Current harmonics
|
||||
i_harmonics = []
|
||||
for i in range(HARMONICS_I_COUNT):
|
||||
off = HARMONICS_I_OFFSET + i * 4
|
||||
i_harmonics.append(struct.unpack_from("<f", data, off)[0])
|
||||
result["I_harmonics"] = i_harmonics
|
||||
result["AThd"] = struct.unpack_from("<f", data, ATHD_OFFSET)[0]
|
||||
|
||||
# Waveforms (int16 LE)
|
||||
v_waveform = []
|
||||
for i in range(WAVEFORM_SAMPLES):
|
||||
off = WAVEFORM_V_OFFSET + i * 2
|
||||
v_waveform.append(struct.unpack_from("<h", data, off)[0])
|
||||
result["V_waveform"] = v_waveform
|
||||
|
||||
i_waveform = []
|
||||
for i in range(WAVEFORM_SAMPLES):
|
||||
off = WAVEFORM_I_OFFSET + i * 2
|
||||
i_waveform.append(struct.unpack_from("<h", data, off)[0])
|
||||
result["I_waveform"] = i_waveform
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def find_hpcs_port():
|
||||
"""Auto-detect the HPCS 6500 COM port."""
|
||||
for port in serial.tools.list_ports.comports():
|
||||
if port.vid == 0x0483 and port.pid == 0x5741:
|
||||
return port.device
|
||||
return None
|
||||
|
||||
|
||||
def format_reading(r, show_spectrum=False):
|
||||
"""Format a reading dict for display."""
|
||||
lines = []
|
||||
lines.append(f" Device: {r.get('device', '?')}")
|
||||
lines.append(f" Date: {r.get('test_date', '?')} Time: {r.get('test_time', '?')}")
|
||||
lines.append("")
|
||||
|
||||
lines.append(" --- Photometric ---")
|
||||
lines.append(f" Phi (lm): {r.get('Phi_lm', 0):10.2f}")
|
||||
lines.append(f" eta (lm/W): {r.get('eta_lm_W', 0):10.2f}")
|
||||
lines.append(f" CCT (K): {r.get('CCT_K', 0):10.0f}")
|
||||
lines.append(f" Duv: {r.get('Duv', 0):10.5f}")
|
||||
lines.append("")
|
||||
|
||||
lines.append(" --- Chromaticity ---")
|
||||
lines.append(f" x: {r.get('x', 0):.4f} y: {r.get('y', 0):.4f}")
|
||||
lines.append(f" u: {r.get('u', 0):.4f} v: {r.get('v', 0):.4f}")
|
||||
lines.append(f" u': {r.get('u_prime', 0):.4f} v': {r.get('v_prime', 0):.4f}")
|
||||
lines.append("")
|
||||
|
||||
lines.append(" --- CRI ---")
|
||||
ra = r.get('Ra', 0)
|
||||
lines.append(f" Ra: {ra:.1f} SDCM: {r.get('SDCM', 0):.2f}")
|
||||
ri = []
|
||||
for i in range(1, 16):
|
||||
ri.append(f"R{i}={r.get(f'R{i}', 0):.0f}")
|
||||
lines.append(f" {', '.join(ri[:8])}")
|
||||
lines.append(f" {', '.join(ri[8:])}")
|
||||
lines.append("")
|
||||
|
||||
lines.append(" --- CIE 1931 ---")
|
||||
lines.append(f" X: {r.get('CIE_X', 0):.3f} Y: {r.get('CIE_Y', 0):.3f} Z: {r.get('CIE_Z', 0):.3f}")
|
||||
lines.append(f" TLCI-2012: {r.get('TLCI', 0):.0f}")
|
||||
lines.append("")
|
||||
|
||||
lines.append(" --- Radiometric ---")
|
||||
lines.append(f" Phi_e (mW): {r.get('Phi_e_mW', 0):10.3f}")
|
||||
lines.append(f" Phi_eb (mW): {r.get('Phi_eb_mW', 0):10.3f}")
|
||||
lines.append(f" Phi_ey (mW): {r.get('Phi_ey_mW', 0):10.3f}")
|
||||
lines.append(f" Phi_er (mW): {r.get('Phi_er_mW', 0):10.3f}")
|
||||
lines.append(f" Phi_efr (mW): {r.get('Phi_efr_mW', 0):10.3f}")
|
||||
lines.append("")
|
||||
|
||||
lines.append(" --- Electrical ---")
|
||||
lines.append(f" Voltage (V): {r.get('Voltage_V', 0):10.2f}")
|
||||
lines.append(f" Current (A): {r.get('Current_A', 0):10.4f}")
|
||||
lines.append(f" Power (W): {r.get('Power_W', 0):10.3f}")
|
||||
lines.append(f" Frequency (Hz):{r.get('Freq_Hz', 0):10.2f}")
|
||||
lines.append(f" Power Factor: {r.get('PF', 0):10.4f}")
|
||||
|
||||
if "UThd" in r:
|
||||
lines.append(f" UThd (%): {r['UThd']:10.4f}")
|
||||
lines.append(f" AThd (%): {r.get('AThd', 0):10.4f}")
|
||||
lines.append("")
|
||||
|
||||
if show_spectrum and "V_harmonics" in r:
|
||||
lines.append(" --- Voltage Harmonics (%) ---")
|
||||
vh = r["V_harmonics"]
|
||||
for i, val in enumerate(vh):
|
||||
if val > 0.001 or i == 0:
|
||||
lines.append(f" H{i+1:2d}: {val:8.4f}")
|
||||
lines.append("")
|
||||
|
||||
lines.append(" --- Current Harmonics (%) ---")
|
||||
ih = r["I_harmonics"]
|
||||
for i, val in enumerate(ih):
|
||||
if val > 0.001 or i == 0:
|
||||
lines.append(f" H{i+1:2d}: {val:8.4f}")
|
||||
lines.append("")
|
||||
|
||||
lines.append(" --- Sensor ---")
|
||||
lines.append(f" Peak Signal: {r.get('PeakSignal', 0):10.0f}")
|
||||
lines.append(f" Dark Signal: {r.get('DarkSignal', 0):10.0f}")
|
||||
lines.append(f" Compensate: {r.get('Compensate', 0):10.0f}")
|
||||
|
||||
if show_spectrum and "spectrum" in r:
|
||||
lines.append("")
|
||||
lines.append(" --- Spectrum (380-1050nm) ---")
|
||||
spectrum = r["spectrum"]
|
||||
nm = r["spectrum_nm"]
|
||||
peak_val = max(spectrum) if spectrum else 1
|
||||
for i, (wl, val) in enumerate(zip(nm, spectrum)):
|
||||
if i % 5 == 0: # Print every 5th point (~10nm spacing)
|
||||
norm = val / peak_val if peak_val > 0 else 0
|
||||
bar = "#" * int(norm * 40)
|
||||
lines.append(f" {wl:7.1f}nm: {val:8.4f} [{bar}]")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_quick(r, reading_num=None):
|
||||
"""Format a reading for quick test mode: lumen + CCT only."""
|
||||
lm = r.get("Phi_lm", 0)
|
||||
cct = r.get("CCT_K", 0)
|
||||
prefix = f"#{reading_num} " if reading_num else ""
|
||||
return f" {prefix}{lm:.1f} lm {cct:.0f} K"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="HPCS 6500 Spectrophotometer Reader")
|
||||
parser.add_argument("--port", help="COM port (auto-detect if not specified)")
|
||||
parser.add_argument("--continuous", action="store_true", help="Continuous reading mode")
|
||||
parser.add_argument("--csv", metavar="FILE", help="Save readings to CSV file")
|
||||
parser.add_argument("--spectrum", action="store_true", help="Show full spectrum data")
|
||||
parser.add_argument("--passive", action="store_true",
|
||||
help="Passive mode: read data without controlling instrument")
|
||||
parser.add_argument("--quick", action="store_true",
|
||||
help="Quick test mode: only report lumen and CCT")
|
||||
parser.add_argument("--harmonics", action="store_true",
|
||||
help="Show harmonics data (voltage/current THD and per-harmonic)")
|
||||
parser.add_argument("--parse", metavar="PCAP", help="Parse a pcap capture file instead")
|
||||
|
||||
# Power supply control
|
||||
psu = parser.add_argument_group("power supply")
|
||||
psu.add_argument("--mode", choices=["ac", "dc"], help="Set output mode")
|
||||
psu.add_argument("--voltage", type=float, help="Set output voltage (V)")
|
||||
psu.add_argument("--frequency", type=float, help="Set AC frequency (Hz)")
|
||||
psu.add_argument("--current", type=float, help="Set DC current limit (A)")
|
||||
psu.add_argument("--integration", type=float, help="Set integration time (ms), 0=auto")
|
||||
psu.add_argument("--psu-on", action="store_true", help="Turn PSU output on")
|
||||
psu.add_argument("--psu-off", action="store_true", help="Turn PSU output off")
|
||||
psu.add_argument("--no-psu", action="store_true",
|
||||
help="Don't auto-control PSU during measurements")
|
||||
psu.add_argument("--psu-status", action="store_true", help="Read power supply settings")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# --harmonics implies --spectrum for the detailed display
|
||||
if args.harmonics:
|
||||
args.spectrum = True
|
||||
|
||||
# Parse mode: analyze an existing capture
|
||||
if args.parse:
|
||||
messages = parse_pcap_messages(args.parse)
|
||||
blocks = [m for m in messages if m["dir"] == "RX" and len(m["data"]) == 3904
|
||||
and m["data"][:8] == b"HPCS6500"]
|
||||
config_blocks = [m for m in messages if m["dir"] == "RX" and len(m["data"]) == 1584]
|
||||
|
||||
dev = HPCS6500.__new__(HPCS6500)
|
||||
for i, block in enumerate(blocks):
|
||||
result = dev._parse_measurement(block["data"])
|
||||
if i < len(config_blocks):
|
||||
result.update(dev._parse_electrical(config_blocks[i]["data"]))
|
||||
if args.quick:
|
||||
print(format_quick(result, i + 1))
|
||||
else:
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Reading {i+1} (t={block['ts']:.2f}s)")
|
||||
print(f"{'='*60}")
|
||||
print(format_reading(result, show_spectrum=args.spectrum))
|
||||
return
|
||||
|
||||
# Find port
|
||||
port = args.port or find_hpcs_port()
|
||||
if not port:
|
||||
print("ERROR: HPCS 6500 not found. Connect the device or specify --port.")
|
||||
print("\nAvailable ports:")
|
||||
for p in serial.tools.list_ports.comports():
|
||||
print(f" {p.device}: {p.description} [{p.hwid}]")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"HPCS 6500 Spectrophotometer Reader")
|
||||
print(f"Port: {port}")
|
||||
print("=" * 60)
|
||||
|
||||
dev = HPCS6500(port)
|
||||
|
||||
# Identify
|
||||
name = dev.identify()
|
||||
if name:
|
||||
print(f"Device: {name}")
|
||||
else:
|
||||
print("Warning: no identify response (device may be busy)")
|
||||
|
||||
# Power supply control
|
||||
has_psu_cmd = any([args.mode, args.voltage is not None, args.frequency is not None,
|
||||
args.current is not None, args.integration is not None,
|
||||
args.psu_status, args.psu_on, args.psu_off])
|
||||
|
||||
if args.psu_status:
|
||||
settings = dev.read_psu_settings()
|
||||
if settings:
|
||||
print(f"\nPower Supply Settings:")
|
||||
print(f" Mode: {settings['mode']}")
|
||||
print(f" AC Voltage: {settings['ac_voltage']:.0f} V")
|
||||
print(f" AC Frequency: {settings['ac_frequency']:.0f} Hz")
|
||||
print(f" DC Voltage: {settings['dc_voltage']:.1f} V")
|
||||
print(f" DC Current: {settings['dc_current']:.1f} A")
|
||||
else:
|
||||
print("Failed to read PSU settings")
|
||||
if not args.continuous and args.voltage is None and args.mode is None:
|
||||
dev.close()
|
||||
return
|
||||
|
||||
if args.mode:
|
||||
ok = dev.set_mode(args.mode)
|
||||
print(f"Set mode to {args.mode.upper()}: {'OK' if ok else 'FAILED'}")
|
||||
|
||||
if args.voltage is not None:
|
||||
mode = args.mode or "ac"
|
||||
if mode == "ac":
|
||||
ok = dev.set_ac_voltage(args.voltage)
|
||||
else:
|
||||
ok = dev.set_dc_voltage(args.voltage)
|
||||
print(f"Set {mode.upper()} voltage to {args.voltage} V: {'OK' if ok else 'FAILED'}")
|
||||
|
||||
if args.frequency is not None:
|
||||
ok = dev.set_ac_frequency(args.frequency)
|
||||
print(f"Set AC frequency to {args.frequency} Hz: {'OK' if ok else 'FAILED'}")
|
||||
|
||||
if args.current is not None:
|
||||
ok = dev.set_dc_current(args.current)
|
||||
print(f"Set DC current limit to {args.current} A: {'OK' if ok else 'FAILED'}")
|
||||
|
||||
if args.integration is not None:
|
||||
ok = dev.set_integration_time(args.integration)
|
||||
print(f"Set integration time to {args.integration} ms: {'OK' if ok else 'FAILED'}")
|
||||
|
||||
if args.psu_on:
|
||||
ok = dev.psu_on()
|
||||
print(f"PSU output ON: {'OK' if ok else 'FAILED'}")
|
||||
|
||||
if args.psu_off:
|
||||
ok = dev.psu_off()
|
||||
print(f"PSU output OFF: {'OK' if ok else 'FAILED'}")
|
||||
|
||||
# If only PSU commands were given with no measurement request, exit
|
||||
if has_psu_cmd and not args.continuous and not args.passive:
|
||||
dev.close()
|
||||
return
|
||||
|
||||
csv_writer = None
|
||||
csv_file = None
|
||||
if args.csv:
|
||||
csv_file = open(args.csv, "w", newline="")
|
||||
fieldnames = ["timestamp"] + list(FIELDS.keys()) + list(ELECTRICAL_FIELDS.keys())
|
||||
csv_writer = csv.DictWriter(csv_file, fieldnames=fieldnames, extrasaction="ignore")
|
||||
csv_writer.writeheader()
|
||||
print(f"Saving to: {args.csv}")
|
||||
|
||||
# Only control PSU if explicitly requested or in continuous mode
|
||||
use_psu = args.continuous and not args.no_psu and not args.passive
|
||||
|
||||
try:
|
||||
if use_psu:
|
||||
dev.psu_on()
|
||||
time.sleep(0.2)
|
||||
|
||||
reading_num = 0
|
||||
while True:
|
||||
reading_num += 1
|
||||
|
||||
if not args.quick:
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Reading {reading_num} ({datetime.now().strftime('%H:%M:%S')})")
|
||||
print(f"{'='*60}")
|
||||
|
||||
if args.passive:
|
||||
result = dev.read_current()
|
||||
elif args.continuous:
|
||||
result = dev.take_reading(psu=False)
|
||||
else:
|
||||
result = dev.take_single_reading()
|
||||
|
||||
if result is None:
|
||||
print("Failed to get reading")
|
||||
if not args.continuous:
|
||||
break
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
if args.quick:
|
||||
print(format_quick(result, reading_num))
|
||||
else:
|
||||
print(format_reading(result, show_spectrum=args.spectrum))
|
||||
|
||||
if csv_writer:
|
||||
row = {"timestamp": datetime.now().isoformat()}
|
||||
row.update({k: v for k, v in result.items()
|
||||
if isinstance(v, (int, float))})
|
||||
csv_writer.writerow(row)
|
||||
csv_file.flush()
|
||||
|
||||
if not args.continuous:
|
||||
break
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopped.")
|
||||
finally:
|
||||
if use_psu:
|
||||
dev.psu_off()
|
||||
dev.reset()
|
||||
dev.close()
|
||||
if csv_file:
|
||||
csv_file.close()
|
||||
print(f"Data saved to: {args.csv}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,112 +0,0 @@
|
||||
"""
|
||||
Lamp ID labels on the Brother QL-820NWB (DK-11221, 23x23 mm die-cut).
|
||||
|
||||
Each label carries the lamp ID as a QR code and as text. These are internal
|
||||
inventory labels: scanning one with the station's barcode scanner types the
|
||||
ID, which drops straight into the re-measure flow. The station prints two
|
||||
per lamp: one for the box, one for the lamp itself.
|
||||
|
||||
Printer address comes from --printer or the HPCS_LABEL_PRINTER environment
|
||||
variable, e.g. tcp://192.168.1.50 (the QL-820NWB listens on port 9100).
|
||||
|
||||
Usage:
|
||||
uv run label_printer.py L0006 # print 2 labels
|
||||
uv run label_printer.py L0006 --copies 1
|
||||
uv run label_printer.py L0006 --preview out.png # render only, no printer
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
import qrcode
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
LABEL_ID = "23x23" # DK-11221
|
||||
DOTS = (202, 202) # printable area of the 23x23 label
|
||||
MODEL = "QL-820NWB"
|
||||
TEXT_HEIGHT = 52 # bottom strip for the ID text
|
||||
|
||||
|
||||
def _font(size):
|
||||
for name in ("arialbd.ttf", "arial.ttf", "DejaVuSans-Bold.ttf"):
|
||||
try:
|
||||
return ImageFont.truetype(name, size)
|
||||
except OSError:
|
||||
continue
|
||||
return ImageFont.load_default(size)
|
||||
|
||||
|
||||
def render_label(lamp_id: str) -> Image.Image:
|
||||
"""202x202 1-bit label: the bare lamp ID as QR on top, as text below."""
|
||||
image = Image.new("1", DOTS, 1)
|
||||
|
||||
# Just the ID (internal use): a 5-character QR stays at version 1, so the
|
||||
# modules print large and scan reliably at 23 mm. Scanning it at the
|
||||
# station types the ID and lands in the re-measure flow.
|
||||
qr = qrcode.QRCode(
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_M,
|
||||
box_size=1,
|
||||
border=2, # quiet zone; tight but fine for close-range scanning
|
||||
)
|
||||
qr.add_data(lamp_id.upper())
|
||||
qr.make(fit=True)
|
||||
modules = len(qr.get_matrix())
|
||||
qr_size = DOTS[1] - TEXT_HEIGHT
|
||||
qr.box_size = max(1, qr_size // modules)
|
||||
qr_img = qr.make_image().get_image().convert("1")
|
||||
image.paste(qr_img, ((DOTS[0] - qr_img.width) // 2, (qr_size - qr_img.height) // 2))
|
||||
|
||||
draw = ImageDraw.Draw(image)
|
||||
size = 44
|
||||
while size > 10:
|
||||
font = _font(size)
|
||||
box = draw.textbbox((0, 0), lamp_id, font=font)
|
||||
if box[2] - box[0] <= DOTS[0] - 8:
|
||||
break
|
||||
size -= 2
|
||||
draw.text(
|
||||
((DOTS[0] - (box[2] - box[0])) // 2 - box[0], DOTS[1] - TEXT_HEIGHT + (TEXT_HEIGHT - (box[3] - box[1])) // 2 - box[1]),
|
||||
lamp_id,
|
||||
font=font,
|
||||
fill=0,
|
||||
)
|
||||
return image
|
||||
|
||||
|
||||
def print_labels(lamp_id: str, printer: str, copies: int = 2) -> None:
|
||||
"""Render and send `copies` labels to the QL-820NWB."""
|
||||
from brother_ql.backends.helpers import send
|
||||
from brother_ql.conversion import convert
|
||||
from brother_ql.raster import BrotherQLRaster
|
||||
|
||||
image = render_label(lamp_id)
|
||||
raster = BrotherQLRaster(MODEL)
|
||||
instructions = convert(qlr=raster, images=[image] * copies, label=LABEL_ID, cut=True)
|
||||
send(instructions=instructions, printer_identifier=printer, backend_identifier="network", blocking=True)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Print lamp ID labels (QR + ID)")
|
||||
parser.add_argument("lamp_id", help="e.g. L0006")
|
||||
parser.add_argument("--printer", default=os.environ.get("HPCS_LABEL_PRINTER", ""),
|
||||
help="tcp://<ip> of the QL-820NWB (default: HPCS_LABEL_PRINTER env)")
|
||||
parser.add_argument("--copies", type=int, default=2)
|
||||
parser.add_argument("--preview", metavar="PNG",
|
||||
help="Write the rendered label to a PNG instead of printing")
|
||||
args = parser.parse_args()
|
||||
|
||||
lamp_id = args.lamp_id.upper()
|
||||
if args.preview:
|
||||
render_label(lamp_id).save(args.preview)
|
||||
print(f"Wrote {args.preview}")
|
||||
return
|
||||
if not args.printer:
|
||||
print("ERROR: no printer configured. Pass --printer tcp://<ip> or set HPCS_LABEL_PRINTER.")
|
||||
sys.exit(1)
|
||||
print_labels(lamp_id, args.printer, args.copies)
|
||||
print(f"Printed {args.copies} label(s) for {lamp_id} on {args.printer}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
-283
@@ -1,283 +0,0 @@
|
||||
"""
|
||||
Lamp comparison export — one measurement, one publishable data bundle.
|
||||
|
||||
Takes a reading from the HPCS 6500 (or an existing pcap capture) and writes
|
||||
the per-lamp files consumed by the buildfor.life comparison pages:
|
||||
|
||||
<out>/<name>/spd.csv full spectrum, wavelength_nm,value (380-1050 nm)
|
||||
<out>/<name>/tm30.csv TM-30-18 hue-bin data for the color vector graphic
|
||||
<out>/<name>/metrics.json photometric / colorimetric / CRI / TM-30 /
|
||||
electrical summary
|
||||
|
||||
Usage:
|
||||
uv run lamp_export.py --name philips-a60-8w
|
||||
uv run lamp_export.py --name x --manufacturer Philips --model "A60 8W 927"
|
||||
uv run lamp_export.py --name x --readings 5 # average 5 readings
|
||||
uv run lamp_export.py --name x --voltage 230 --frequency 50 --settle 60
|
||||
uv run lamp_export.py --name x --passive # vendor SW drives the instrument
|
||||
uv run lamp_export.py --name x --parse captures/run.pcap
|
||||
|
||||
With --voltage/--frequency/--current/--mode (or --psu), the built-in supply is
|
||||
configured, switched on for the measurement, and switched off afterwards.
|
||||
--settle waits after power-on so the lamp stabilizes before the first reading.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from hpcs6500 import HPCS6500, find_hpcs_port, parse_pcap_messages
|
||||
|
||||
SCALAR_GROUPS = {
|
||||
"photometric": ["Phi_lm", "eta_lm_W", "CCT_K", "Duv", "SDCM", "TLCI"],
|
||||
"chromaticity": ["x", "y", "u", "v", "u_prime", "v_prime", "CIE_X", "CIE_Y", "CIE_Z"],
|
||||
"radiometric": [
|
||||
"Phi_e_mW", "Phi_euv_mW", "Phi_eb_mW", "Phi_ey_mW",
|
||||
"Phi_er_mW", "Phi_efr_mW", "Phi_eir_mW", "Phi_e_total",
|
||||
],
|
||||
"electrical": ["Voltage_V", "Current_A", "Power_W", "Freq_Hz", "PF", "UThd", "AThd"],
|
||||
"sensor": ["PeakSignal", "DarkSignal", "Compensate"],
|
||||
}
|
||||
|
||||
|
||||
def average_readings(readings):
|
||||
"""Element-wise average of scalars and spectra across readings."""
|
||||
result = dict(readings[0])
|
||||
n = len(readings)
|
||||
if n == 1:
|
||||
return result
|
||||
for key, value in readings[0].items():
|
||||
if isinstance(value, (int, float)):
|
||||
result[key] = sum(r.get(key, 0.0) for r in readings) / n
|
||||
elif key == "spectrum":
|
||||
result[key] = [
|
||||
sum(r["spectrum"][i] for r in readings) / n
|
||||
for i in range(len(value))
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
def readings_from_pcap(path):
|
||||
"""Extract parsed readings from a pcap capture."""
|
||||
messages = parse_pcap_messages(path)
|
||||
blocks = [m for m in messages if m["dir"] == "RX" and len(m["data"]) == 3904
|
||||
and m["data"][:8] == b"HPCS6500"]
|
||||
elec_blocks = [m for m in messages if m["dir"] == "RX" and len(m["data"]) == 1584]
|
||||
dev = HPCS6500.__new__(HPCS6500)
|
||||
readings = []
|
||||
for i, block in enumerate(blocks):
|
||||
r = dev._parse_measurement(block["data"])
|
||||
if i < len(elec_blocks):
|
||||
r.update(dev._parse_electrical(elec_blocks[i]["data"]))
|
||||
readings.append(r)
|
||||
return readings
|
||||
|
||||
|
||||
def readings_from_device(port, count, passive, psu):
|
||||
dev = HPCS6500(port)
|
||||
name = dev.identify()
|
||||
if name:
|
||||
print(f"Device: {name}")
|
||||
readings = []
|
||||
supply = None
|
||||
try:
|
||||
if psu["mode"]:
|
||||
ok = dev.set_mode(psu["mode"])
|
||||
print(f"Mode {psu['mode'].upper()}: {'OK' if ok else 'FAILED'}")
|
||||
if psu["voltage"] is not None:
|
||||
if psu["mode"] == "dc":
|
||||
ok = dev.set_dc_voltage(psu["voltage"])
|
||||
else:
|
||||
ok = dev.set_ac_voltage(psu["voltage"])
|
||||
print(f"Voltage {psu['voltage']:g} V: {'OK' if ok else 'FAILED'}")
|
||||
if psu["frequency"] is not None:
|
||||
ok = dev.set_ac_frequency(psu["frequency"])
|
||||
print(f"Frequency {psu['frequency']:g} Hz: {'OK' if ok else 'FAILED'}")
|
||||
if psu["current"] is not None:
|
||||
ok = dev.set_dc_current(psu["current"])
|
||||
print(f"DC current limit {psu['current']:g} A: {'OK' if ok else 'FAILED'}")
|
||||
|
||||
supply = dev.read_psu_settings()
|
||||
|
||||
if psu["enable"]:
|
||||
if not dev.psu_on():
|
||||
print("ERROR: failed to turn PSU on")
|
||||
sys.exit(1)
|
||||
print("PSU on")
|
||||
if psu["settle"] > 0:
|
||||
print(f"Settling {psu['settle']:g} s ...")
|
||||
time.sleep(psu["settle"])
|
||||
else:
|
||||
time.sleep(0.2)
|
||||
|
||||
for i in range(count):
|
||||
print(f"Reading {i + 1}/{count} ...")
|
||||
if passive:
|
||||
r = dev.read_current()
|
||||
else:
|
||||
# Vendor single-shot cycle (verified from USB captures):
|
||||
# test config -> trigger (8C 0E 02) -> poll -> read -> reset,
|
||||
# repeated per reading. auto_psu stays off; we hold the PSU.
|
||||
dev.send_test_config(auto_psu=False)
|
||||
r = dev.take_single_reading()
|
||||
if r is None:
|
||||
print("ERROR: failed to get reading")
|
||||
sys.exit(1)
|
||||
readings.append(r)
|
||||
if i < count - 1:
|
||||
if psu["enable"] and not passive:
|
||||
# take_single_reading() ends with an instrument reset;
|
||||
# make sure the lamp stays powered for the next reading.
|
||||
dev.psu_on()
|
||||
if psu["interval"] > 0:
|
||||
time.sleep(psu["interval"])
|
||||
finally:
|
||||
if psu["enable"]:
|
||||
dev.psu_off()
|
||||
print("PSU off")
|
||||
dev.close()
|
||||
return readings, supply
|
||||
|
||||
|
||||
def write_bundle(reading, out_dir, meta, n_readings, supply=None):
|
||||
# Deferred: importing colour-science takes a few seconds, so it happens
|
||||
# after the readings rather than at startup.
|
||||
from tm30 import compute_tm30
|
||||
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
spectrum = reading.get("spectrum") or []
|
||||
nm = reading.get("spectrum_nm") or []
|
||||
if not spectrum or max(spectrum) <= 0:
|
||||
print("ERROR: reading contains no spectrum data")
|
||||
sys.exit(1)
|
||||
|
||||
# spd.csv — full measured range; consumers trim to visible as needed.
|
||||
with open(out_dir / "spd.csv", "w", newline="") as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(["wavelength_nm", "value"])
|
||||
for wl, val in zip(nm, spectrum):
|
||||
w.writerow([f"{wl:.2f}", f"{val:.6g}"])
|
||||
|
||||
# TM-30 from the same spectrum.
|
||||
tm30 = compute_tm30(nm, spectrum)
|
||||
bin_fields = list(tm30["bins"][0].keys())
|
||||
with open(out_dir / "tm30.csv", "w", newline="") as f:
|
||||
w = csv.DictWriter(f, fieldnames=bin_fields)
|
||||
w.writeheader()
|
||||
for b in tm30["bins"]:
|
||||
w.writerow({k: (f"{v:.6g}" if isinstance(v, float) else v) for k, v in b.items()})
|
||||
|
||||
# metrics.json — grouped scalars plus identification. ean/variant are set
|
||||
# by lamp_station.py when one barcode covers several versions of a lamp
|
||||
# (e.g. daylight and warm white sharing box art and EAN).
|
||||
metrics = {"name": meta["name"]}
|
||||
if meta.get("ean"):
|
||||
metrics["ean"] = meta["ean"]
|
||||
if meta.get("type"):
|
||||
# Lamp technology: led, halogen, cfl, incandescent, ...
|
||||
metrics["type"] = meta["type"]
|
||||
if meta.get("dimmable") is not None:
|
||||
metrics["dimmable"] = meta["dimmable"]
|
||||
if meta.get("variant"):
|
||||
metrics["variant"] = meta["variant"]
|
||||
if meta.get("rated"):
|
||||
# Advertised values from the packaging, keyed like their measured
|
||||
# counterparts so consumers can pair them (truth-in-advertising rows).
|
||||
metrics["rated"] = meta["rated"]
|
||||
metrics |= {
|
||||
"manufacturer": meta["manufacturer"],
|
||||
"model": meta["model"],
|
||||
"notes": meta["notes"],
|
||||
"instrument": reading.get("device", "HPCS6500"),
|
||||
"measured_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
"instrument_date": reading.get("test_date"),
|
||||
"instrument_time": reading.get("test_time"),
|
||||
"readings_averaged": n_readings,
|
||||
"tm30": {"Rf": round(tm30["Rf"], 1), "Rg": round(tm30["Rg"], 1)},
|
||||
"cri": {"Ra": reading.get("Ra")}
|
||||
| {f"R{i}": reading.get(f"R{i}") for i in range(1, 16)},
|
||||
}
|
||||
if supply:
|
||||
metrics["supply"] = supply
|
||||
for group, keys in SCALAR_GROUPS.items():
|
||||
metrics[group] = {k: reading[k] for k in keys if k in reading}
|
||||
with open(out_dir / "metrics.json", "w") as f:
|
||||
json.dump(metrics, f, indent=2)
|
||||
|
||||
return tm30
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Export a lamp measurement bundle")
|
||||
parser.add_argument("--name", required=True,
|
||||
help="Lamp slug, becomes the output directory name")
|
||||
parser.add_argument("--manufacturer", default="")
|
||||
parser.add_argument("--model", default="")
|
||||
parser.add_argument("--notes", default="")
|
||||
parser.add_argument("--out", default="lamps", help="Output base directory")
|
||||
parser.add_argument("--port", help="COM port (auto-detect if omitted)")
|
||||
parser.add_argument("--readings", type=int, default=1,
|
||||
help="Number of readings to average (default 1)")
|
||||
parser.add_argument("--passive", action="store_true",
|
||||
help="Read without controlling the instrument")
|
||||
parser.add_argument("--parse", metavar="PCAP",
|
||||
help="Export from a pcap capture instead of the device")
|
||||
|
||||
psu_group = parser.add_argument_group("power supply")
|
||||
psu_group.add_argument("--mode", choices=["ac", "dc"],
|
||||
help="Supply mode (default ac when --voltage is given)")
|
||||
psu_group.add_argument("--voltage", type=float, help="Supply voltage (V)")
|
||||
psu_group.add_argument("--frequency", type=float, help="AC frequency (Hz)")
|
||||
psu_group.add_argument("--current", type=float, help="DC current limit (A)")
|
||||
psu_group.add_argument("--psu", action="store_true",
|
||||
help="Power the lamp from the built-in supply "
|
||||
"(implied by --mode/--voltage/--frequency/--current)")
|
||||
psu_group.add_argument("--settle", type=float, default=0,
|
||||
help="Seconds to wait after PSU on before the first reading")
|
||||
parser.add_argument("--interval", type=float, default=1.0,
|
||||
help="Seconds to wait between readings (default 1)")
|
||||
args = parser.parse_args()
|
||||
|
||||
psu = {
|
||||
"mode": args.mode,
|
||||
"voltage": args.voltage,
|
||||
"frequency": args.frequency,
|
||||
"current": args.current,
|
||||
"settle": args.settle,
|
||||
"interval": args.interval,
|
||||
"enable": args.psu or args.mode is not None or args.voltage is not None
|
||||
or args.frequency is not None or args.current is not None,
|
||||
}
|
||||
|
||||
supply = None
|
||||
if args.parse:
|
||||
readings = readings_from_pcap(args.parse)
|
||||
if not readings:
|
||||
print(f"ERROR: no measurement blocks in {args.parse}")
|
||||
sys.exit(1)
|
||||
print(f"Using {len(readings)} reading(s) from capture")
|
||||
else:
|
||||
port = args.port or find_hpcs_port()
|
||||
if not port:
|
||||
print("ERROR: HPCS 6500 not found. Connect the device or specify --port.")
|
||||
sys.exit(1)
|
||||
readings, supply = readings_from_device(port, args.readings, args.passive, psu)
|
||||
|
||||
reading = average_readings(readings)
|
||||
out_dir = Path(args.out) / args.name
|
||||
meta = {k: getattr(args, k) for k in ("name", "manufacturer", "model", "notes")}
|
||||
tm30 = write_bundle(reading, out_dir, meta, len(readings), supply)
|
||||
|
||||
print(f"\nWrote {out_dir}/spd.csv, tm30.csv, metrics.json")
|
||||
print(f" {reading.get('Phi_lm', 0):.0f} lm {reading.get('eta_lm_W', 0):.1f} lm/W "
|
||||
f"{reading.get('CCT_K', 0):.0f} K Ra {reading.get('Ra', 0):.1f}")
|
||||
print(f" TM-30: Rf {tm30['Rf']:.1f} Rg {tm30['Rg']:.1f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
-392
@@ -1,392 +0,0 @@
|
||||
"""
|
||||
Lamp measurement station — scan a barcode, measure, commit, done.
|
||||
|
||||
Interactive loop for building the buildfor.life lamp comparison database.
|
||||
Every lamp gets an internal ID (L0001, L0002, ...) as its primary key; the
|
||||
EAN barcode is optional metadata. This keeps lamps distinct when one barcode
|
||||
covers several versions (daylight / warm white in the same box art) and
|
||||
supports unbranded lamps without any barcode.
|
||||
|
||||
1. Every run opens with a freshly allocated ID (L0012, ...). The ID only
|
||||
becomes real once the bundle is written, so aborting never burns one.
|
||||
2. Scan the box with a keyboard-wedge barcode scanner (types EAN +
|
||||
Enter), press Enter for a lamp without a barcode, or type an existing
|
||||
ID (e.g. L0004) to re-measure that lamp with everything pre-filled,
|
||||
which also rewrites its bundle in the current metrics format. Scanned
|
||||
EANs are checksum-validated; lamps already measured with the same EAN
|
||||
are listed (pick one to re-measure, or continue as the new lamp: that
|
||||
is how same-EAN variants get their own IDs). Identification is
|
||||
suggested from previous lamps, a local cache, then upcitemdb.com
|
||||
(best effort; GS1-restricted 20-29 barcodes skip the online lookup).
|
||||
3. Confirm or edit manufacturer / model / variant / notes, then type the
|
||||
ADVERTISED values from the packaging (lumen, CCT, watts, CRI,
|
||||
lifetime, equivalent watts; Enter skips unknown ones). They are
|
||||
stored under "rated" in metrics.json so the site can show
|
||||
advertised-vs-measured deltas.
|
||||
4. Insert the lamp, press Enter: the built-in supply powers it per the
|
||||
published procedure (230 V / 50 Hz, 60 s settle, 5 readings averaged)
|
||||
and the bundle (spd.csv, tm30.csv, metrics.json) is written to
|
||||
<data-repo>/lamps/<ID>/.
|
||||
5. The bundle is committed to the comparison-data repo and pushed. The
|
||||
website picks it up automatically (data-driven device list plus the
|
||||
auto-bump deploy); an .md page in the web repo is only ever needed
|
||||
for photos, where-to-buy links, or notes.
|
||||
6. Two ID labels (QR to the lamp's page + the ID, DK-11221 23x23 mm)
|
||||
print on the Brother QL-820NWB: one for the box, one for the lamp.
|
||||
Configure the printer via HPCS_LABEL_PRINTER or --printer; reprint
|
||||
any lamp later with `uv run label_printer.py L0006`.
|
||||
|
||||
Usage:
|
||||
uv run lamp_station.py # defaults below
|
||||
uv run lamp_station.py --data-repo D:/comparison-data --no-push
|
||||
uv run lamp_station.py --settle 0 --readings 1 # quick smoke test
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from hpcs6500 import find_hpcs_port
|
||||
from lamp_export import average_readings, readings_from_device, write_bundle
|
||||
|
||||
DEFAULT_DATA_REPO = Path(r"C:\dev\buildfor_life_web\apps\web\comparison-data")
|
||||
CACHE_FILE = Path.home() / ".cache" / "hpcs6500" / "ean-cache.json"
|
||||
LOOKUP_URL = "https://api.upcitemdb.com/prod/trial/lookup?upc={}"
|
||||
ID_RE = re.compile(r"^L(\d{4,})$")
|
||||
|
||||
# Advertised values typed from the packaging; keys match the measured
|
||||
# metrics they are compared against on the site.
|
||||
RATED_FIELDS = [
|
||||
("Phi_lm", "Rated flux (lm)"),
|
||||
("CCT_K", "Rated CCT (K)"),
|
||||
("Power_W", "Rated power (W)"),
|
||||
("Ra", "Rated CRI (Ra)"),
|
||||
("lifetime_h", "Rated lifetime (h)"),
|
||||
("W_equiv", "Incandescent equivalent (W)"),
|
||||
]
|
||||
|
||||
|
||||
def ean_normalize(code):
|
||||
"""Return a valid EAN-13 (UPC-A gets a leading zero), or None."""
|
||||
code = re.sub(r"\D", "", code)
|
||||
if len(code) == 12:
|
||||
code = "0" + code
|
||||
if len(code) != 13:
|
||||
return None
|
||||
digits = [int(c) for c in code]
|
||||
check = (10 - sum(d * (3 if i % 2 else 1) for i, d in enumerate(digits[:12])) % 10) % 10
|
||||
return code if check == digits[12] else None
|
||||
|
||||
|
||||
def is_restricted(ean):
|
||||
"""GS1 prefixes 20-29 are retailer-internal, never publicly resolvable."""
|
||||
return 20 <= int(ean[:2]) <= 29
|
||||
|
||||
|
||||
def lamp_index(data_repo):
|
||||
"""{id: metrics-dict} for every measured lamp."""
|
||||
lamps = {}
|
||||
for f in sorted((data_repo / "lamps").glob("L*/metrics.json")):
|
||||
if ID_RE.match(f.parent.name):
|
||||
try:
|
||||
lamps[f.parent.name] = json.loads(f.read_text())
|
||||
except ValueError:
|
||||
pass
|
||||
return lamps
|
||||
|
||||
|
||||
def next_lamp_id(data_repo):
|
||||
used = [int(ID_RE.match(d.name).group(1))
|
||||
for d in (data_repo / "lamps").glob("L*") if ID_RE.match(d.name)]
|
||||
return f"L{max(used, default=0) + 1:04d}"
|
||||
|
||||
|
||||
# Canonical spellings for brands; extended at runtime by whatever is already
|
||||
# in the database and the cache, so the first accepted spelling of a new brand
|
||||
# becomes its canonical form for every later scan.
|
||||
BRAND_SEED = [
|
||||
"Philips", "OSRAM", "LAMPTAN", "TKL", "ST", "IKEA", "Panasonic",
|
||||
"Toshiba", "GE", "Sylvania", "Xiaomi", "Opple", "EVE",
|
||||
]
|
||||
|
||||
|
||||
def canonical_brand(name, lamps):
|
||||
"""Map a manufacturer string to its canonical capitalization."""
|
||||
name = " ".join(name.split())
|
||||
if not name:
|
||||
return name
|
||||
brands = {b.lower(): b for b in BRAND_SEED}
|
||||
for entry in load_cache().values():
|
||||
b = entry.get("manufacturer", "")
|
||||
if b:
|
||||
brands.setdefault(b.lower(), b)
|
||||
for m in lamps.values():
|
||||
b = m.get("manufacturer", "")
|
||||
if b:
|
||||
brands[b.lower()] = b # the database is the strongest source
|
||||
return brands.get(name.lower(), name)
|
||||
|
||||
|
||||
def load_cache():
|
||||
try:
|
||||
return json.loads(CACHE_FILE.read_text())
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def save_cache(cache):
|
||||
CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
CACHE_FILE.write_text(json.dumps(cache, indent=2))
|
||||
|
||||
|
||||
def lookup_ean(ean, lamps):
|
||||
"""Best-effort (manufacturer, model, source) for an EAN."""
|
||||
for m in lamps.values():
|
||||
if m.get("ean") == ean and (m.get("manufacturer") or m.get("model")):
|
||||
return m.get("manufacturer", ""), m.get("model", ""), f"previous lamp {m['name']}"
|
||||
|
||||
cache = load_cache()
|
||||
if ean in cache:
|
||||
return cache[ean]["manufacturer"], cache[ean]["model"], "local cache"
|
||||
|
||||
if is_restricted(ean):
|
||||
return "", "", "retailer-internal barcode, not resolvable"
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(LOOKUP_URL.format(ean),
|
||||
headers={"User-Agent": "hpcs6500-lamp-station"})
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read())
|
||||
item = (data.get("items") or [{}])[0]
|
||||
brand, title = item.get("brand", ""), item.get("title", "")
|
||||
if brand or title:
|
||||
return brand, title, "upcitemdb.com (verify against packaging)"
|
||||
except Exception as e: # noqa: BLE001 — lookup is strictly best-effort
|
||||
print(f" (online lookup failed: {e})")
|
||||
return "", "", "no match"
|
||||
|
||||
|
||||
def prompt(label, default=""):
|
||||
text = input(f"{label} [{default}]: " if default else f"{label}: ").strip()
|
||||
return text or default
|
||||
|
||||
|
||||
def prompt_rated(previous=None):
|
||||
"""Advertised values from the box; Enter skips a field."""
|
||||
previous = previous or {}
|
||||
print(" Advertised values from the packaging (Enter to skip):")
|
||||
rated = {}
|
||||
for key, label in RATED_FIELDS:
|
||||
default = previous.get(key, "")
|
||||
raw = prompt(f" {label}", str(default) if default != "" else "")
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
value = float(raw)
|
||||
rated[key] = int(value) if value == int(value) else value
|
||||
except ValueError:
|
||||
print(f" (not a number, skipped: {raw!r})")
|
||||
return rated
|
||||
|
||||
|
||||
def git(repo, *args, check=True):
|
||||
return subprocess.run(["git", "-C", str(repo), *args],
|
||||
check=check, capture_output=True, text=True)
|
||||
|
||||
|
||||
def commit_bundle(data_repo, lamp_id, label, existed, push):
|
||||
git(data_repo, "add", f"lamps/{lamp_id}")
|
||||
if not git(data_repo, "status", "--porcelain", f"lamps/{lamp_id}").stdout.strip():
|
||||
print("No data changes to commit.")
|
||||
return
|
||||
verb = "Re-measure" if existed else "Add"
|
||||
git(data_repo, "commit", "-m", f"{verb} lamp {lamp_id} ({label})")
|
||||
print(f"Committed to comparison-data: {verb} lamp {lamp_id} ({label})")
|
||||
if push:
|
||||
git(data_repo, "push")
|
||||
print("Pushed.")
|
||||
|
||||
|
||||
def measure_one(new_id, ean, lamps, args, data_repo, existing_id=None):
|
||||
# The run already owns its fresh ID (new_id). Typing an existing ID at
|
||||
# the scan prompt re-measures that lamp instead (all its identification
|
||||
# pre-filled, bundle rewritten in the current format); an EAN may also
|
||||
# redirect to a re-measure when it matches an existing lamp.
|
||||
lamp_id, previous = new_id, {}
|
||||
if existing_id:
|
||||
lamp_id = existing_id
|
||||
previous = lamps[existing_id]
|
||||
ean = previous.get("ean", "")
|
||||
print(f" Re-measuring {lamp_id}" + (f" (EAN {ean})" if ean else ""))
|
||||
elif ean:
|
||||
same = {i: m for i, m in lamps.items() if m.get("ean") == ean}
|
||||
if same:
|
||||
print(" Lamps already measured with this EAN:")
|
||||
for i, m in same.items():
|
||||
variant = f", {m['variant']}" if m.get("variant") else ""
|
||||
print(f" {i}: {m.get('manufacturer', '')} {m.get('model', '')}{variant}")
|
||||
choice = prompt(f"Re-measure one of these (type its ID) or Enter to continue as {new_id}").strip()
|
||||
if choice:
|
||||
if choice.upper() not in same:
|
||||
print(f" Unknown ID {choice!r}, aborting this run.")
|
||||
return
|
||||
lamp_id = choice.upper()
|
||||
previous = same[lamp_id]
|
||||
print(f" Re-measuring {lamp_id}")
|
||||
|
||||
if previous:
|
||||
manufacturer, model = previous.get("manufacturer", ""), previous.get("model", "")
|
||||
elif ean:
|
||||
manufacturer, model, source = lookup_ean(ean, lamps)
|
||||
print(f" Lookup: {manufacturer or '?'} / {model or '?'} ({source})")
|
||||
else:
|
||||
manufacturer = model = ""
|
||||
|
||||
manufacturer = prompt("Manufacturer", canonical_brand(manufacturer, lamps))
|
||||
canon = canonical_brand(manufacturer, lamps)
|
||||
if canon != manufacturer:
|
||||
print(f" Using canonical brand name: {canon}")
|
||||
manufacturer = canon
|
||||
model = prompt("Model", model)
|
||||
lamp_type = prompt("Type (led / halogen / cfl / incandescent / ...)",
|
||||
previous.get("type", "led")).lower().strip()
|
||||
prev_dim = previous.get("dimmable")
|
||||
dim_default = "" if prev_dim is None else ("y" if prev_dim else "n")
|
||||
dim_raw = prompt("Dimmable? (y / n, Enter if unknown)", dim_default).lower()
|
||||
dimmable = True if dim_raw.startswith("y") else False if dim_raw.startswith("n") else None
|
||||
variant = prompt("Variant (e.g. daylight / warm white, Enter if none)",
|
||||
previous.get("variant", "")).lower()
|
||||
notes = prompt("Notes", previous.get("notes", "retailer-internal barcode, brand unknown"
|
||||
if ean and is_restricted(ean) and not manufacturer else ""))
|
||||
rated = prompt_rated(previous.get("rated"))
|
||||
|
||||
if ean and (manufacturer or model):
|
||||
cache = load_cache()
|
||||
cache[ean] = {"manufacturer": manufacturer, "model": model}
|
||||
save_cache(cache)
|
||||
|
||||
input("Insert the lamp, close the sphere, press Enter to start ... ")
|
||||
|
||||
port = args.port or find_hpcs_port()
|
||||
if not port:
|
||||
print("ERROR: HPCS 6500 not found. Connect the device or use --port.")
|
||||
return
|
||||
psu = {"mode": "ac", "voltage": args.voltage, "frequency": args.frequency,
|
||||
"current": None, "settle": args.settle, "interval": args.interval,
|
||||
"enable": True}
|
||||
readings, supply = readings_from_device(port, args.readings, False, psu)
|
||||
reading = average_readings(readings)
|
||||
meta = {"name": lamp_id, "ean": ean or "", "type": lamp_type, "dimmable": dimmable,
|
||||
"variant": variant, "manufacturer": manufacturer, "model": model,
|
||||
"notes": notes, "rated": rated}
|
||||
out_dir = data_repo / "lamps" / lamp_id
|
||||
tm30 = write_bundle(reading, out_dir, meta, len(readings), supply)
|
||||
|
||||
print(f"\n {reading.get('Phi_lm', 0):.0f} lm {reading.get('eta_lm_W', 0):.1f} lm/W "
|
||||
f"{reading.get('CCT_K', 0):.0f} K Ra {reading.get('Ra', 0):.1f} "
|
||||
f"TM-30 Rf {tm30['Rf']:.1f} / Rg {tm30['Rg']:.1f}")
|
||||
if rated.get("Phi_lm"):
|
||||
delta = reading.get("Phi_lm", 0) / rated["Phi_lm"] * 100 - 100
|
||||
print(f" vs advertised: {delta:+.1f} % flux", end="")
|
||||
if rated.get("CCT_K"):
|
||||
print(f", {reading.get('CCT_K', 0) - rated['CCT_K']:+.0f} K CCT", end="")
|
||||
print()
|
||||
print()
|
||||
|
||||
label_parts = [x for x in (manufacturer, model) if x]
|
||||
if variant:
|
||||
label_parts.append(variant)
|
||||
if ean:
|
||||
label_parts.append(f"EAN {ean}")
|
||||
if not args.no_commit:
|
||||
commit_bundle(data_repo, lamp_id, ", ".join(label_parts) or "unidentified",
|
||||
existed=bool(previous), push=not args.no_push)
|
||||
print(" The lamp publishes automatically (auto-bump deploy); add an .md under")
|
||||
print(" src/content/comparisons/lamps/ only for photos, buy links, or notes.")
|
||||
|
||||
# Two ID labels on the QL-820NWB: one for the box, one for the lamp.
|
||||
if not args.no_labels:
|
||||
if args.printer:
|
||||
try:
|
||||
from label_printer import print_labels
|
||||
print_labels(lamp_id, args.printer, copies=2)
|
||||
print(f" Printed 2 ID labels for {lamp_id}.")
|
||||
except Exception as e: # noqa: BLE001 — a printer problem must not lose the run
|
||||
print(f" Label printing failed ({e}); reprint later with:")
|
||||
print(f" uv run label_printer.py {lamp_id}")
|
||||
else:
|
||||
print(f" (no label printer configured; set HPCS_LABEL_PRINTER or use --printer,")
|
||||
print(f" reprint later with: uv run label_printer.py {lamp_id})")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Barcode-driven lamp measurement station")
|
||||
parser.add_argument("--data-repo", default=str(DEFAULT_DATA_REPO),
|
||||
help="comparison-data checkout (bundles land in lamps/<ID>/)")
|
||||
parser.add_argument("--port", help="COM port (auto-detect if omitted)")
|
||||
parser.add_argument("--readings", type=int, default=5)
|
||||
parser.add_argument("--voltage", type=float, default=230.0)
|
||||
parser.add_argument("--frequency", type=float, default=50.0)
|
||||
parser.add_argument("--settle", type=float, default=60.0,
|
||||
help="Seconds of stabilization after PSU on (procedure: 60)")
|
||||
parser.add_argument("--interval", type=float, default=1.0)
|
||||
parser.add_argument("--no-commit", action="store_true",
|
||||
help="Write the bundle but skip git entirely")
|
||||
parser.add_argument("--no-push", action="store_true",
|
||||
help="Commit locally but do not push")
|
||||
parser.add_argument("--printer", default=os.environ.get("HPCS_LABEL_PRINTER", ""),
|
||||
help="QL-820NWB address, e.g. tcp://192.168.1.50 "
|
||||
"(default: HPCS_LABEL_PRINTER env)")
|
||||
parser.add_argument("--no-labels", action="store_true",
|
||||
help="Skip printing the two ID labels after each lamp")
|
||||
args = parser.parse_args()
|
||||
|
||||
data_repo = Path(args.data_repo)
|
||||
# In a submodule checkout .git is a file, not a directory; exists() covers both.
|
||||
if not (data_repo / ".git").exists():
|
||||
print(f"ERROR: {data_repo} is not a git checkout of comparison-data.")
|
||||
sys.exit(1)
|
||||
|
||||
print("Lamp station ready. Every run starts a new lamp ID; the EAN is optional")
|
||||
print("(scan it, or press Enter to skip). 'q' quits.")
|
||||
while True:
|
||||
# A fresh ID opens every run. It only becomes real once a bundle is
|
||||
# written, so quitting or re-measuring never burns a number.
|
||||
lamps = lamp_index(data_repo)
|
||||
new_id = next_lamp_id(data_repo)
|
||||
print(f"\n--- New lamp {new_id} ---")
|
||||
try:
|
||||
code = input("Scan EAN (Enter to skip, L#### re-measures an existing lamp, q quits): ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
break
|
||||
if code.lower() in ("q", "quit", "exit"):
|
||||
break
|
||||
existing_id = None
|
||||
ean = ""
|
||||
if ID_RE.match(code.upper()):
|
||||
existing_id = code.upper()
|
||||
if existing_id not in lamps:
|
||||
print(f" No lamp {existing_id} in the database.")
|
||||
continue
|
||||
elif code and code.lower() not in ("n", "none"):
|
||||
ean = ean_normalize(code)
|
||||
if not ean:
|
||||
print(f" Not a valid EAN-13/UPC-A: {code!r} (Enter would skip the EAN)")
|
||||
continue
|
||||
try:
|
||||
measure_one(new_id, ean, lamps, args, data_repo, existing_id=existing_id)
|
||||
except KeyboardInterrupt:
|
||||
print("\n Aborted; lamp skipped. (PSU is switched off by the driver.)")
|
||||
except (subprocess.CalledProcessError, SystemExit) as e:
|
||||
err = getattr(e, "stderr", "") or e
|
||||
print(f" ERROR: {err}")
|
||||
print("Bye.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,13 +0,0 @@
|
||||
[project]
|
||||
name = "hpcs6500"
|
||||
version = "0.1.0"
|
||||
description = "Open-source driver and CLI for the HPCS 6500 spectrophotometer / integrating sphere"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"pyserial>=3.5",
|
||||
"colour-science>=0.4.4",
|
||||
"qrcode>=8.2",
|
||||
"pillow>=12.3.0",
|
||||
"brother-ql-next>=0.12.0",
|
||||
]
|
||||
@@ -1,42 +0,0 @@
|
||||
"""Thin wrapper for external tools — outputs JSON to stdout."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
from hpcs6500 import HPCS6500, find_hpcs_port
|
||||
|
||||
|
||||
def measure():
|
||||
port = find_hpcs_port()
|
||||
if not port:
|
||||
print('{"error": "HPCS 6500 not found"}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
dev = HPCS6500(port)
|
||||
try:
|
||||
dev.identify()
|
||||
reading = dev.take_single_reading()
|
||||
finally:
|
||||
dev.close()
|
||||
|
||||
if reading is None:
|
||||
print('{"error": "measurement failed"}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
result = {
|
||||
"lumen": round(reading["Phi_lm"], 1),
|
||||
"cct": round(reading["CCT_K"]),
|
||||
}
|
||||
print(json.dumps(result))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="HPCS 6500 JSON interface")
|
||||
parser.add_argument("--measure", action="store_true", help="Take a measurement and output JSON")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.measure:
|
||||
measure()
|
||||
else:
|
||||
parser.print_help()
|
||||
@@ -0,0 +1,297 @@
|
||||
//! Reverse-engineering tools: capture the vendor software's USB traffic
|
||||
//! with USBPcap, and dump a capture (timeline, TX/RX byte streams).
|
||||
|
||||
use hpcs6500::pcap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
fn hex_spaced(b: &[u8]) -> String {
|
||||
b.iter()
|
||||
.map(|x| format!("{x:02x}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn ascii(b: &[u8]) -> String {
|
||||
b.iter()
|
||||
.map(|&c| {
|
||||
if (32..127).contains(&c) {
|
||||
c as char
|
||||
} else {
|
||||
'.'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Timeline of the bulk transfers, the TX/RX streams as .bin files and a
|
||||
/// quick look at both streams.
|
||||
pub fn dump(path: &Path, limit: usize, write_bin: bool) -> i32 {
|
||||
let data = match std::fs::read(path) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
eprintln!("ERROR: {}: {e}", path.display());
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
let (hdr, packets) = match pcap::packets(&data) {
|
||||
Ok(x) => x,
|
||||
Err(e) => {
|
||||
eprintln!("ERROR: {e}");
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
println!(
|
||||
"PCAP: v{}.{}, snaplen={}, link_type={}",
|
||||
hdr.version.0, hdr.version.1, hdr.snaplen, hdr.link_type
|
||||
);
|
||||
if hdr.link_type != 249 {
|
||||
println!(
|
||||
"Warning: expected link_type 249 (USBPcap), got {}",
|
||||
hdr.link_type
|
||||
);
|
||||
}
|
||||
let total = packets.last().map(|p| p.num).unwrap_or(0);
|
||||
let bulk: Vec<&pcap::Packet> = packets.iter().filter(|p| p.is_bulk()).collect();
|
||||
let is_rx = |p: &pcap::Packet| p.info & 1 == 1 || p.endpoint & 0x80 != 0;
|
||||
let (mut tx, mut rx) = (vec![], vec![]);
|
||||
for p in &bulk {
|
||||
if is_rx(p) {
|
||||
rx.extend_from_slice(&p.data)
|
||||
} else {
|
||||
tx.extend_from_slice(&p.data)
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"\nParsed {total} total packets, {} bulk transfers with data",
|
||||
bulk.len()
|
||||
);
|
||||
println!("TX (host -> device): {} bytes", tx.len());
|
||||
println!("RX (device -> host): {} bytes", rx.len());
|
||||
if bulk.is_empty() {
|
||||
println!("\nNo bulk transfer data found.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
println!("\n{}\nTRAFFIC TIMELINE\n{}", "=".repeat(80), "=".repeat(80));
|
||||
let first = bulk[0].ts;
|
||||
for p in bulk.iter().take(limit) {
|
||||
let (colour, label) = if is_rx(p) {
|
||||
("\x1b[92m", "DEV->PC")
|
||||
} else {
|
||||
("\x1b[93m", "PC->DEV")
|
||||
};
|
||||
let mut h = hex_spaced(&p.data);
|
||||
if h.len() > 90 {
|
||||
h = format!("{} ... (+{}B)", &h[..90], p.data.len().saturating_sub(30));
|
||||
}
|
||||
println!(
|
||||
"{colour}[{:8.4}] {label} EP{:02X} ({:4}B): {h}\x1b[0m",
|
||||
p.ts - first,
|
||||
p.endpoint,
|
||||
p.data.len()
|
||||
);
|
||||
let printable = p.data.iter().filter(|c| (32..127).contains(*c)).count();
|
||||
if printable as f64 > p.data.len() as f64 * 0.4 {
|
||||
println!(
|
||||
" ASCII: {:?}",
|
||||
ascii(&p.data[..p.data.len().min(40)])
|
||||
);
|
||||
}
|
||||
}
|
||||
if bulk.len() > limit {
|
||||
println!(" ... ({} more packets)", bulk.len() - limit);
|
||||
}
|
||||
|
||||
if write_bin {
|
||||
let stem = path.with_extension("");
|
||||
for (suffix, stream) in [("_TX.bin", &tx), ("_RX.bin", &rx)] {
|
||||
if stream.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let out = PathBuf::from(format!("{}{suffix}", stem.display()));
|
||||
match std::fs::write(&out, stream) {
|
||||
Ok(_) => println!("\n{} data saved to: {}", &suffix[1..3], out.display()),
|
||||
Err(e) => println!("\nERROR writing {}: {e}", out.display()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n{}\nQUICK ANALYSIS\n{}", "=".repeat(80), "=".repeat(80));
|
||||
for (label, s) in [("TX (PC -> Device)", &tx), ("RX (Device -> PC)", &rx)] {
|
||||
if s.is_empty() {
|
||||
continue;
|
||||
}
|
||||
println!("\n--- {label}: {} bytes ---", s.len());
|
||||
let printable = s
|
||||
.iter()
|
||||
.filter(|&&c| (32..127).contains(&c) || c == 0x0A || c == 0x0D)
|
||||
.count();
|
||||
println!(
|
||||
" Printable ratio: {:.0}%",
|
||||
printable as f64 / s.len() as f64 * 100.0
|
||||
);
|
||||
let sample = &s[..s.len().min(200)];
|
||||
println!(" HEX: {}", hex_spaced(sample));
|
||||
println!(" ASCII: {}", ascii(sample));
|
||||
if s.len() > 10 {
|
||||
for (name, d) in [
|
||||
("\\r\\n", &b"\r\n"[..]),
|
||||
("\\n", b"\n"),
|
||||
("0xFF", b"\xff"),
|
||||
("0xAA", b"\xaa"),
|
||||
] {
|
||||
let count = count_non_overlapping(s, d);
|
||||
if count > 2 {
|
||||
println!(" Delimiter {name}: appears {count} times");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
fn count_non_overlapping(hay: &[u8], needle: &[u8]) -> usize {
|
||||
let (mut i, mut n) = (0, 0);
|
||||
while i + needle.len() <= hay.len() {
|
||||
if &hay[i..i + needle.len()] == needle {
|
||||
n += 1;
|
||||
i += needle.len();
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Start USBPcapCMD on every root hub at once (the instrument's hub is not
|
||||
/// known in advance), wait for Ctrl+C, keep the largest capture and dump it.
|
||||
pub fn capture(address: u32, out_dir: &Path, usbpcap: &Path) -> i32 {
|
||||
if !cfg!(windows) {
|
||||
eprintln!(
|
||||
"ERROR: capturing needs USBPcap on Windows; use Wireshark/usbmon elsewhere and `hpcs6500 dump`."
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
if !usbpcap.exists() {
|
||||
eprintln!(
|
||||
"ERROR: {} not found. Install USBPcap (and reboot), then run as administrator.",
|
||||
usbpcap.display()
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
if let Err(e) = std::fs::create_dir_all(out_dir) {
|
||||
eprintln!("ERROR: {}: {e}", out_dir.display());
|
||||
return 1;
|
||||
}
|
||||
println!("HPCS6500 USB Traffic Capture\n{}", "=".repeat(60));
|
||||
println!("Device address: {address}\n");
|
||||
let stamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
|
||||
let mut procs: Vec<(Child, String, PathBuf)> = vec![];
|
||||
for i in 1..=10 {
|
||||
let device = format!(r"\\.\USBPcap{i}");
|
||||
let file = out_dir.join(format!("usb_capture_{stamp}_hub{i}.pcap"));
|
||||
let child = Command::new(usbpcap)
|
||||
.args(["-d", &device, "-o"])
|
||||
.arg(&file)
|
||||
.args([
|
||||
"-A",
|
||||
"--devices",
|
||||
&address.to_string(),
|
||||
"-s",
|
||||
"65535",
|
||||
"-b",
|
||||
"1048576",
|
||||
])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn();
|
||||
match child {
|
||||
Ok(c) => {
|
||||
println!("Starting capture on {device} -> {}", file.display());
|
||||
procs.push((c, device, file));
|
||||
}
|
||||
Err(e) => println!(" {device}: {e}"),
|
||||
}
|
||||
}
|
||||
if procs.is_empty() {
|
||||
println!(
|
||||
"ERROR: no USBPcap capture could be started (run as ADMINISTRATOR, reboot after installing USBPcap)."
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
println!("\nCapturing on ALL hubs simultaneously.");
|
||||
println!(">>> Now use the vendor software - take a measurement");
|
||||
println!(">>> Press Ctrl+C when done\n{}", "-".repeat(60));
|
||||
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
{
|
||||
let stop = stop.clone();
|
||||
let _ = ctrlc::set_handler(move || stop.store(true, Ordering::SeqCst));
|
||||
}
|
||||
loop {
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
println!("\n\nStopping captures...");
|
||||
break;
|
||||
}
|
||||
if procs
|
||||
.iter_mut()
|
||||
.all(|(c, _, _)| matches!(c.try_wait(), Ok(Some(_))))
|
||||
{
|
||||
println!("\nAll capture processes have exited.");
|
||||
for (c, device, _) in procs.iter_mut() {
|
||||
let mut err = String::new();
|
||||
if let Some(mut s) = c.stderr.take() {
|
||||
let _ = std::io::Read::read_to_string(&mut s, &mut err);
|
||||
}
|
||||
let code = c.try_wait().ok().flatten().and_then(|s| s.code());
|
||||
println!(
|
||||
" {device}: exit code {code:?}{}",
|
||||
if err.trim().is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" - {}", err.trim())
|
||||
}
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
for (c, _, _) in procs.iter_mut() {
|
||||
let _ = c.kill();
|
||||
let _ = c.wait();
|
||||
}
|
||||
|
||||
println!();
|
||||
let mut best: Option<(PathBuf, u64)> = None;
|
||||
for (_, device, file) in &procs {
|
||||
match std::fs::metadata(file) {
|
||||
Ok(m) => {
|
||||
println!("{device} -> {}: {} bytes", file.display(), m.len());
|
||||
if best.as_ref().is_none_or(|(_, s)| m.len() > *s) {
|
||||
best = Some((file.clone(), m.len()));
|
||||
}
|
||||
}
|
||||
Err(_) => println!("{device} -> no file created"),
|
||||
}
|
||||
}
|
||||
match best {
|
||||
Some((file, size)) if size > 24 => {
|
||||
println!(
|
||||
"\nBest capture: {} ({size} bytes)\n\nParsing capture...\n",
|
||||
file.display()
|
||||
);
|
||||
dump(&file, 100, true)
|
||||
}
|
||||
_ => {
|
||||
println!(
|
||||
"\nNo USB data captured from any hub.\nMake sure you're running as ADMINISTRATOR."
|
||||
);
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
+427
@@ -0,0 +1,427 @@
|
||||
//! `hpcs6500`: command line tool for the HPCS 6500.
|
||||
//!
|
||||
//! The flags are those of the former Python tool (`hpcs6500.py`), so
|
||||
//! `uv run hpcs6500.py --continuous --csv out.csv` becomes
|
||||
//! `hpcs6500 --continuous --csv out.csv`.
|
||||
|
||||
mod capture;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use hpcs6500::format::{csv_header, csv_row, format_quick, format_reading, pcap_report};
|
||||
use hpcs6500::sim::Simulator;
|
||||
use hpcs6500::{Error, Hpcs6500, Mode, Reading, Transport, pcap};
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "hpcs6500",
|
||||
version,
|
||||
about = "HPCS 6500 Spectrophotometer Reader"
|
||||
)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Option<Command>,
|
||||
|
||||
/// COM port (auto-detect if not specified)
|
||||
#[arg(long)]
|
||||
port: Option<String>,
|
||||
/// Continuous reading mode
|
||||
#[arg(long)]
|
||||
continuous: bool,
|
||||
/// With --continuous: stop after this many readings
|
||||
#[arg(long, value_name = "N")]
|
||||
count: Option<usize>,
|
||||
/// Save readings to CSV file
|
||||
#[arg(long, value_name = "FILE")]
|
||||
csv: Option<PathBuf>,
|
||||
/// Show full spectrum data
|
||||
#[arg(long)]
|
||||
spectrum: bool,
|
||||
/// Passive mode: read data without controlling instrument
|
||||
#[arg(long)]
|
||||
passive: bool,
|
||||
/// Quick test mode: only report lumen and CCT
|
||||
#[arg(long)]
|
||||
quick: bool,
|
||||
/// Show harmonics data (voltage/current THD and per-harmonic)
|
||||
#[arg(long)]
|
||||
harmonics: bool,
|
||||
/// Parse a pcap capture file instead
|
||||
#[arg(long, value_name = "PCAP")]
|
||||
parse: Option<PathBuf>,
|
||||
/// Print each reading as one JSON line on stdout (messages go to stderr)
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
/// Talk to a simulated instrument instead of the serial port; replays
|
||||
/// the readings of a USB capture, or a built-in sample without one
|
||||
#[arg(long, value_name = "PCAP", num_args = 0..=1, default_missing_value = "")]
|
||||
simulate: Option<String>,
|
||||
|
||||
/// Set output mode
|
||||
#[arg(long, help_heading = "Power supply")]
|
||||
mode: Option<Mode>,
|
||||
/// Set output voltage (V)
|
||||
#[arg(long, help_heading = "Power supply")]
|
||||
voltage: Option<f64>,
|
||||
/// Set AC frequency (Hz)
|
||||
#[arg(long, help_heading = "Power supply")]
|
||||
frequency: Option<f64>,
|
||||
/// Set DC current limit (A)
|
||||
#[arg(long, help_heading = "Power supply")]
|
||||
current: Option<f64>,
|
||||
/// Set integration time (ms), 0=auto
|
||||
#[arg(long, help_heading = "Power supply")]
|
||||
integration: Option<f64>,
|
||||
/// Turn PSU output on
|
||||
#[arg(long, help_heading = "Power supply")]
|
||||
psu_on: bool,
|
||||
/// Turn PSU output off
|
||||
#[arg(long, help_heading = "Power supply")]
|
||||
psu_off: bool,
|
||||
/// Don't auto-control PSU during measurements
|
||||
#[arg(long, help_heading = "Power supply")]
|
||||
no_psu: bool,
|
||||
/// Read power supply settings
|
||||
#[arg(long, help_heading = "Power supply")]
|
||||
psu_status: bool,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// List serial ports (the HPCS 6500 is marked)
|
||||
Ports,
|
||||
/// Show the USB traffic in a capture and save the TX/RX byte streams
|
||||
Dump {
|
||||
pcap: PathBuf,
|
||||
/// Packets to show in the timeline
|
||||
#[arg(long, default_value_t = 100)]
|
||||
limit: usize,
|
||||
/// Do not write <name>_TX.bin / <name>_RX.bin
|
||||
#[arg(long)]
|
||||
no_bin: bool,
|
||||
},
|
||||
/// Capture the USB traffic of the vendor software with USBPcap (Windows, run as administrator)
|
||||
Capture {
|
||||
/// USB device address of the instrument on its hub
|
||||
#[arg(long, default_value_t = 4)]
|
||||
address: u32,
|
||||
#[arg(long, default_value = "captures")]
|
||||
out_dir: PathBuf,
|
||||
#[arg(long, default_value = r"C:\Program Files\USBPcap\USBPcapCMD.exe")]
|
||||
usbpcap: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
/// Human messages: stdout normally, stderr with --json.
|
||||
macro_rules! say {
|
||||
($json:expr, $($t:tt)*) => {
|
||||
if $json { eprintln!($($t)*) } else { println!($($t)*) }
|
||||
};
|
||||
}
|
||||
|
||||
fn ok(r: &hpcs6500::Result<()>) -> &'static str {
|
||||
if r.is_ok() { "OK" } else { "FAILED" }
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
let code = match &cli.command {
|
||||
Some(Command::Ports) => ports(),
|
||||
Some(Command::Dump {
|
||||
pcap,
|
||||
limit,
|
||||
no_bin,
|
||||
}) => capture::dump(pcap, *limit, !*no_bin),
|
||||
Some(Command::Capture {
|
||||
address,
|
||||
out_dir,
|
||||
usbpcap,
|
||||
}) => capture::capture(*address, out_dir, usbpcap),
|
||||
None => run(cli),
|
||||
};
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
fn ports() -> i32 {
|
||||
for p in hpcs6500::list_ports() {
|
||||
println!(
|
||||
" {}: {} [{}]{}",
|
||||
p.name,
|
||||
p.description,
|
||||
p.hwid,
|
||||
if p.is_hpcs { " <- HPCS 6500" } else { "" }
|
||||
);
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
fn run(mut cli: Cli) -> i32 {
|
||||
// --harmonics implies --spectrum for the detailed display
|
||||
if cli.harmonics {
|
||||
cli.spectrum = true;
|
||||
}
|
||||
|
||||
if let Some(path) = &cli.parse {
|
||||
return match pcap::read_messages(path) {
|
||||
Ok(msgs) if cli.json => {
|
||||
for r in pcap::readings(&msgs) {
|
||||
println!("{}", serde_json::to_string(&r).expect("json"));
|
||||
}
|
||||
0
|
||||
}
|
||||
Ok(msgs) => {
|
||||
print!("{}", pcap_report(&msgs, cli.quick, cli.spectrum));
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("ERROR: {e}");
|
||||
1
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
match &cli.simulate {
|
||||
Some(src) => {
|
||||
let sim = if src.is_empty() {
|
||||
Ok(Simulator::sample())
|
||||
} else {
|
||||
Simulator::from_pcap(src)
|
||||
};
|
||||
match sim {
|
||||
Ok(s) => {
|
||||
let label = if src.is_empty() {
|
||||
"simulator (built-in sample)".to_string()
|
||||
} else {
|
||||
format!("simulator ({src})")
|
||||
};
|
||||
session(&cli, Hpcs6500::with_transport(s, label))
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("ERROR: {e}");
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let Some(port) = cli.port.clone().or_else(hpcs6500::find_port) else {
|
||||
println!("ERROR: HPCS 6500 not found. Connect the device or specify --port.");
|
||||
println!("\nAvailable ports:");
|
||||
ports();
|
||||
return 1;
|
||||
};
|
||||
match Hpcs6500::open(&port) {
|
||||
Ok(dev) => session(&cli, dev),
|
||||
Err(e) => {
|
||||
eprintln!("ERROR: cannot open {port}: {e}");
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn session<T: Transport>(cli: &Cli, mut dev: Hpcs6500<T>) -> i32 {
|
||||
let j = cli.json;
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
{
|
||||
let stop = stop.clone();
|
||||
let _ = ctrlc::set_handler(move || stop.store(true, Ordering::SeqCst));
|
||||
}
|
||||
dev.set_cancel_flag(stop.clone());
|
||||
|
||||
say!(j, "HPCS 6500 Spectrophotometer Reader");
|
||||
say!(j, "Port: {}", dev.port());
|
||||
say!(j, "{}", "=".repeat(60));
|
||||
|
||||
match dev.identify() {
|
||||
Ok(name) => say!(j, "Device: {name}"),
|
||||
Err(_) => say!(j, "Warning: no identify response (device may be busy)"),
|
||||
}
|
||||
|
||||
let has_psu_cmd = cli.mode.is_some()
|
||||
|| cli.voltage.is_some()
|
||||
|| cli.frequency.is_some()
|
||||
|| cli.current.is_some()
|
||||
|| cli.integration.is_some()
|
||||
|| cli.psu_status
|
||||
|| cli.psu_on
|
||||
|| cli.psu_off;
|
||||
|
||||
if cli.psu_status {
|
||||
match dev.read_psu_settings() {
|
||||
Ok(s) => {
|
||||
say!(j, "\nPower Supply Settings:");
|
||||
say!(j, " Mode: {}", s.mode);
|
||||
say!(j, " AC Voltage: {:.0} V", s.ac_voltage);
|
||||
say!(j, " AC Frequency: {:.0} Hz", s.ac_frequency);
|
||||
say!(j, " DC Voltage: {:.1} V", s.dc_voltage);
|
||||
say!(j, " DC Current: {:.1} A", s.dc_current);
|
||||
}
|
||||
Err(_) => say!(j, "Failed to read PSU settings"),
|
||||
}
|
||||
if !cli.continuous && cli.voltage.is_none() && cli.mode.is_none() {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(m) = cli.mode {
|
||||
let r = dev.set_mode(m);
|
||||
say!(j, "Set mode to {m}: {}", ok(&r));
|
||||
}
|
||||
if let Some(v) = cli.voltage {
|
||||
let mode = cli.mode.unwrap_or(Mode::Ac);
|
||||
let r = if mode == Mode::Ac {
|
||||
dev.set_ac_voltage(v as f32)
|
||||
} else {
|
||||
dev.set_dc_voltage(v as f32)
|
||||
};
|
||||
say!(
|
||||
j,
|
||||
"Set {mode} voltage to {} V: {}",
|
||||
hpcs6500::pyfmt::repr(v),
|
||||
ok(&r)
|
||||
);
|
||||
}
|
||||
if let Some(f) = cli.frequency {
|
||||
let r = dev.set_ac_frequency(f as f32);
|
||||
say!(
|
||||
j,
|
||||
"Set AC frequency to {} Hz: {}",
|
||||
hpcs6500::pyfmt::repr(f),
|
||||
ok(&r)
|
||||
);
|
||||
}
|
||||
if let Some(a) = cli.current {
|
||||
let r = dev.set_dc_current(a as f32);
|
||||
say!(
|
||||
j,
|
||||
"Set DC current limit to {} A: {}",
|
||||
hpcs6500::pyfmt::repr(a),
|
||||
ok(&r)
|
||||
);
|
||||
}
|
||||
if let Some(ms) = cli.integration {
|
||||
let r = dev.set_integration_time(ms);
|
||||
say!(
|
||||
j,
|
||||
"Set integration time to {} ms: {}",
|
||||
hpcs6500::pyfmt::repr(ms),
|
||||
ok(&r)
|
||||
);
|
||||
}
|
||||
if cli.psu_on {
|
||||
let r = dev.psu_on();
|
||||
say!(j, "PSU output ON: {}", ok(&r));
|
||||
}
|
||||
if cli.psu_off {
|
||||
let r = dev.psu_off();
|
||||
say!(j, "PSU output OFF: {}", ok(&r));
|
||||
}
|
||||
|
||||
// only supply commands, no measurement requested
|
||||
if has_psu_cmd && !cli.continuous && !cli.passive {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut csv = None;
|
||||
if let Some(path) = &cli.csv {
|
||||
match File::create(path) {
|
||||
Ok(mut f) => {
|
||||
let _ = f.write_all(csv_header().as_bytes());
|
||||
say!(j, "Saving to: {}", path.display());
|
||||
csv = Some(f);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("ERROR: {}: {e}", path.display());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// the supply is only controlled in continuous mode
|
||||
let use_psu = cli.continuous && !cli.no_psu && !cli.passive;
|
||||
let mut code = 0;
|
||||
if use_psu {
|
||||
let _ = dev.psu_on();
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
|
||||
let mut n = 0;
|
||||
loop {
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
say!(j, "\nStopped.");
|
||||
break;
|
||||
}
|
||||
n += 1;
|
||||
if !cli.quick {
|
||||
say!(j, "\n{}", "=".repeat(60));
|
||||
say!(
|
||||
j,
|
||||
"Reading {n} ({})",
|
||||
chrono::Local::now().format("%H:%M:%S")
|
||||
);
|
||||
say!(j, "{}", "=".repeat(60));
|
||||
}
|
||||
let result: hpcs6500::Result<Reading> = if cli.passive {
|
||||
dev.read_current()
|
||||
} else if cli.continuous {
|
||||
dev.take_reading(false)
|
||||
} else {
|
||||
dev.take_single_reading()
|
||||
};
|
||||
let r = match result {
|
||||
Ok(r) => r,
|
||||
Err(Error::Cancelled) => {
|
||||
say!(j, "\nStopped.");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
say!(j, "Failed to get reading ({e})");
|
||||
if !cli.continuous {
|
||||
code = 1;
|
||||
break;
|
||||
}
|
||||
let _ = dev.pause(Duration::from_secs(1));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if j {
|
||||
println!("{}", serde_json::to_string(&r).expect("json"));
|
||||
} else if cli.quick {
|
||||
println!("{}", format_quick(&r, Some(n)));
|
||||
} else {
|
||||
println!("{}", format_reading(&r, cli.spectrum));
|
||||
}
|
||||
if let Some(f) = csv.as_mut() {
|
||||
let now = chrono::Local::now().naive_local();
|
||||
let ts = if now.and_utc().timestamp_subsec_micros() == 0 {
|
||||
now.format("%Y-%m-%dT%H:%M:%S").to_string()
|
||||
} else {
|
||||
now.format("%Y-%m-%dT%H:%M:%S%.6f").to_string()
|
||||
};
|
||||
let _ = f.write_all(csv_row(&ts, &r).as_bytes());
|
||||
let _ = f.flush();
|
||||
}
|
||||
if !cli.continuous || cli.count.is_some_and(|c| n >= c) {
|
||||
break;
|
||||
}
|
||||
if dev.pause(Duration::from_millis(500)).is_err() {
|
||||
say!(j, "\nStopped.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if use_psu {
|
||||
let _ = dev.psu_off();
|
||||
}
|
||||
let _ = dev.reset();
|
||||
if let Some(path) = &cli.csv {
|
||||
say!(j, "Data saved to: {}", path.display());
|
||||
}
|
||||
code
|
||||
}
|
||||
+446
@@ -0,0 +1,446 @@
|
||||
//! The instrument over a byte transport (the serial port, or the simulator).
|
||||
|
||||
use crate::error::{Error, Result, hex};
|
||||
use crate::protocol::{self, Mode, PollState, PsuSettings, SYNC, cmd};
|
||||
use crate::reading::Reading;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// A byte pipe to the instrument.
|
||||
pub trait Transport: Send {
|
||||
/// Write a whole frame.
|
||||
fn write_all(&mut self, data: &[u8]) -> io::Result<()>;
|
||||
/// Read what is available, waiting up to `timeout` for the first byte.
|
||||
/// Returns `Ok(0)` when nothing arrived in time.
|
||||
fn read_some(&mut self, buf: &mut [u8], timeout: Duration) -> io::Result<usize>;
|
||||
/// Drop unread input.
|
||||
fn clear_input(&mut self) -> io::Result<()>;
|
||||
}
|
||||
|
||||
/// The USB virtual COM port.
|
||||
pub struct SerialTransport {
|
||||
port: Box<dyn serialport::SerialPort>,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
impl SerialTransport {
|
||||
pub fn open(name: &str) -> Result<Self> {
|
||||
let timeout = Duration::from_millis(100);
|
||||
let mut port = serialport::new(name, protocol::BAUD)
|
||||
.data_bits(serialport::DataBits::Eight)
|
||||
.parity(serialport::Parity::None)
|
||||
.stop_bits(serialport::StopBits::One)
|
||||
.flow_control(serialport::FlowControl::None)
|
||||
.timeout(timeout)
|
||||
.open()?;
|
||||
// pyserial raises DTR and RTS on open; the vendor software does too
|
||||
let _ = port.write_data_terminal_ready(true);
|
||||
let _ = port.write_request_to_send(true);
|
||||
Ok(SerialTransport { port, timeout })
|
||||
}
|
||||
}
|
||||
|
||||
impl Transport for SerialTransport {
|
||||
fn write_all(&mut self, data: &[u8]) -> io::Result<()> {
|
||||
self.port.write_all(data)?;
|
||||
self.port.flush()
|
||||
}
|
||||
|
||||
fn read_some(&mut self, buf: &mut [u8], timeout: Duration) -> io::Result<usize> {
|
||||
if timeout != self.timeout {
|
||||
self.port.set_timeout(timeout).map_err(io::Error::other)?;
|
||||
self.timeout = timeout;
|
||||
}
|
||||
match self.port.read(buf) {
|
||||
Ok(n) => Ok(n),
|
||||
Err(e) if e.kind() == io::ErrorKind::TimedOut => Ok(0),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_input(&mut self) -> io::Result<()> {
|
||||
self.port
|
||||
.clear(serialport::ClearBuffer::Input)
|
||||
.map_err(io::Error::other)
|
||||
}
|
||||
}
|
||||
|
||||
/// A serial port as listed by [`list_ports`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PortInfo {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
/// `USB VID:PID=0483:5741 SER=...` or the port type.
|
||||
pub hwid: String,
|
||||
pub is_hpcs: bool,
|
||||
}
|
||||
|
||||
pub fn list_ports() -> Vec<PortInfo> {
|
||||
let Ok(ports) = serialport::available_ports() else {
|
||||
return vec![];
|
||||
};
|
||||
ports
|
||||
.into_iter()
|
||||
.map(|p| match p.port_type {
|
||||
serialport::SerialPortType::UsbPort(u) => PortInfo {
|
||||
description: u
|
||||
.product
|
||||
.clone()
|
||||
.or(u.manufacturer.clone())
|
||||
.unwrap_or_else(|| "USB serial".into()),
|
||||
hwid: format!(
|
||||
"USB VID:PID={:04X}:{:04X}{}",
|
||||
u.vid,
|
||||
u.pid,
|
||||
u.serial_number
|
||||
.as_ref()
|
||||
.map(|s| format!(" SER={s}"))
|
||||
.unwrap_or_default()
|
||||
),
|
||||
is_hpcs: u.vid == protocol::USB_VID && u.pid == protocol::USB_PID,
|
||||
name: p.port_name,
|
||||
},
|
||||
t => PortInfo {
|
||||
description: "n/a".into(),
|
||||
hwid: format!("{t:?}"),
|
||||
is_hpcs: false,
|
||||
name: p.port_name,
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The first serial port with the instrument's USB IDs.
|
||||
pub fn find_port() -> Option<String> {
|
||||
list_ports().into_iter().find(|p| p.is_hpcs).map(|p| p.name)
|
||||
}
|
||||
|
||||
const ACK_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const HEADER_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
const BLOCK_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
/// How long a reading may take before giving up.
|
||||
pub const DATA_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// The HPCS 6500.
|
||||
///
|
||||
/// Every command clears pending input, sends one frame and reads the
|
||||
/// answer. Methods return `Err` where the Python driver returned
|
||||
/// `None`/`False`.
|
||||
pub struct Hpcs6500<T: Transport = SerialTransport> {
|
||||
io: T,
|
||||
port: String,
|
||||
cancel: Option<Arc<AtomicBool>>,
|
||||
}
|
||||
|
||||
impl Hpcs6500<SerialTransport> {
|
||||
/// Open the instrument on a serial port (e.g. `COM42`, `/dev/ttyACM0`).
|
||||
pub fn open(port: &str) -> Result<Self> {
|
||||
Ok(Hpcs6500 {
|
||||
io: SerialTransport::open(port)?,
|
||||
port: port.to_string(),
|
||||
cancel: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Transport> Hpcs6500<T> {
|
||||
/// Use any transport, e.g. a [`crate::sim::Simulator`].
|
||||
pub fn with_transport(io: T, port: impl Into<String>) -> Self {
|
||||
Hpcs6500 {
|
||||
io,
|
||||
port: port.into(),
|
||||
cancel: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn port(&self) -> &str {
|
||||
&self.port
|
||||
}
|
||||
pub fn transport(&self) -> &T {
|
||||
&self.io
|
||||
}
|
||||
pub fn transport_mut(&mut self) -> &mut T {
|
||||
&mut self.io
|
||||
}
|
||||
pub fn into_transport(self) -> T {
|
||||
self.io
|
||||
}
|
||||
|
||||
/// Long waits (data polling, [`Hpcs6500::pause`]) end with
|
||||
/// [`Error::Cancelled`] when this flag goes up, e.g. from a Ctrl+C handler.
|
||||
pub fn set_cancel_flag(&mut self, flag: Arc<AtomicBool>) {
|
||||
self.cancel = Some(flag);
|
||||
}
|
||||
|
||||
fn cancelled(&self) -> bool {
|
||||
self.cancel
|
||||
.as_ref()
|
||||
.is_some_and(|f| f.load(Ordering::SeqCst))
|
||||
}
|
||||
|
||||
/// Sleep, but return [`Error::Cancelled`] early when the cancel flag goes up.
|
||||
pub fn pause(&self, d: Duration) -> Result<()> {
|
||||
let end = Instant::now() + d;
|
||||
loop {
|
||||
if self.cancelled() {
|
||||
return Err(Error::Cancelled);
|
||||
}
|
||||
let now = Instant::now();
|
||||
if now >= end {
|
||||
return Ok(());
|
||||
}
|
||||
std::thread::sleep((end - now).min(Duration::from_millis(50)));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- framing ----
|
||||
|
||||
fn send(&mut self, frame: &[u8]) -> Result<()> {
|
||||
self.io.clear_input()?;
|
||||
self.io.write_all(frame)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Up to `n` bytes, fewer if the deadline passes.
|
||||
fn recv(&mut self, n: usize, timeout: Duration) -> Result<Vec<u8>> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
let mut buf = vec![0u8; n];
|
||||
let mut got = 0;
|
||||
while got < n {
|
||||
let now = Instant::now();
|
||||
if now >= deadline {
|
||||
break;
|
||||
}
|
||||
got += self.io.read_some(
|
||||
&mut buf[got..],
|
||||
(deadline - now).min(Duration::from_millis(100)),
|
||||
)?;
|
||||
}
|
||||
buf.truncate(got);
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Send a write command and expect the `8C <cmd>` acknowledge.
|
||||
fn command(&mut self, frame: &[u8]) -> Result<()> {
|
||||
self.send(frame)?;
|
||||
let cmd = frame[1];
|
||||
let r = self.recv(2, ACK_TIMEOUT)?;
|
||||
if r == [SYNC, cmd] {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::NoAck { cmd, got: hex(&r) })
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a read command, check the header, return the whole response.
|
||||
fn query(&mut self, cmd: u8, len: usize, timeout: Duration) -> Result<Vec<u8>> {
|
||||
self.send(&protocol::frame(cmd, &[]))?;
|
||||
let r = self.recv(len, timeout)?;
|
||||
if r.len() < 2 || r[0] != SYNC || r[1] != cmd {
|
||||
return Err(Error::BadResponse { cmd, got: hex(&r) });
|
||||
}
|
||||
if r.len() < len {
|
||||
return Err(Error::Short {
|
||||
what: "response",
|
||||
got: r.len(),
|
||||
want: len,
|
||||
});
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn read_block(&mut self, cmd: u8, len: usize, what: &'static str) -> Result<Vec<u8>> {
|
||||
self.send(&protocol::frame(cmd, &[]))?;
|
||||
let hdr = self.recv(4, HEADER_TIMEOUT)?;
|
||||
if hdr.len() < 4 || hdr[0] != SYNC || hdr[1] != cmd {
|
||||
return Err(Error::BadResponse {
|
||||
cmd,
|
||||
got: hex(&hdr),
|
||||
});
|
||||
}
|
||||
let payload = self.recv(len, BLOCK_TIMEOUT)?;
|
||||
if payload.len() < len {
|
||||
return Err(Error::Short {
|
||||
what,
|
||||
got: payload.len(),
|
||||
want: len,
|
||||
});
|
||||
}
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
// ---- commands ----
|
||||
|
||||
/// `8C 00`: the device name, e.g. "HPCS6500".
|
||||
pub fn identify(&mut self) -> Result<String> {
|
||||
let r = self.query(cmd::IDENTIFY, 16, ACK_TIMEOUT)?;
|
||||
let name: String = r[2..10]
|
||||
.iter()
|
||||
.map(|&b| if b < 0x80 { b as char } else { '\u{FFFD}' })
|
||||
.collect();
|
||||
Ok(name.trim_end_matches('\0').to_string())
|
||||
}
|
||||
|
||||
/// `8C 13`: the raw 3904-byte measurement block.
|
||||
pub fn read_measurement_block(&mut self) -> Result<Vec<u8>> {
|
||||
self.read_block(
|
||||
cmd::READ_MEASUREMENT,
|
||||
protocol::MEASUREMENT_LEN,
|
||||
"measurement block",
|
||||
)
|
||||
}
|
||||
|
||||
/// `8C 77`: the raw 1584-byte electrical / harmonics block.
|
||||
pub fn read_electrical_block(&mut self) -> Result<Vec<u8>> {
|
||||
self.read_block(
|
||||
cmd::READ_ELECTRICAL,
|
||||
protocol::ELECTRICAL_LEN,
|
||||
"electrical block",
|
||||
)
|
||||
}
|
||||
|
||||
/// `8C 03`: is a reading ready?
|
||||
pub fn poll_state(&mut self) -> Result<PollState> {
|
||||
let r = self.query(cmd::POLL_STATE, 9, ACK_TIMEOUT)?;
|
||||
Ok(PollState {
|
||||
data_available: r[2] == 1,
|
||||
state: r[5],
|
||||
})
|
||||
}
|
||||
|
||||
/// `8C 2B` sub 00 + sub 01: the vendor's single-test configuration.
|
||||
/// With `auto_psu` the instrument switches its supply on and off around the test.
|
||||
pub fn send_test_config(&mut self, auto_psu: bool) -> Result<()> {
|
||||
for f in protocol::test_config_frames(auto_psu) {
|
||||
self.command(&f)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `8C 0E 01`: start continuous measuring.
|
||||
pub fn start_measurement(&mut self) -> Result<()> {
|
||||
self.command(&protocol::frame(cmd::START, &[protocol::START_CONTINUOUS]))
|
||||
}
|
||||
|
||||
/// `8C 0E 02`: stop measuring; on an idle instrument this triggers one reading.
|
||||
pub fn stop_measurement(&mut self) -> Result<()> {
|
||||
self.command(&protocol::frame(cmd::START, &[protocol::STOP_OR_SINGLE]))
|
||||
}
|
||||
|
||||
/// `8C 25`.
|
||||
pub fn reset(&mut self) -> Result<()> {
|
||||
self.command(&protocol::frame(cmd::RESET, &[]))
|
||||
}
|
||||
|
||||
/// `8C 72 00`: supply output on.
|
||||
pub fn psu_on(&mut self) -> Result<()> {
|
||||
self.command(&protocol::frame(cmd::PSU_OUTPUT, &[0x00]))
|
||||
}
|
||||
|
||||
/// `8C 72 01`: supply output off.
|
||||
pub fn psu_off(&mut self) -> Result<()> {
|
||||
self.command(&protocol::frame(cmd::PSU_OUTPUT, &[0x01]))
|
||||
}
|
||||
|
||||
/// `8C 7A`: AC or DC output.
|
||||
pub fn set_mode(&mut self, mode: Mode) -> Result<()> {
|
||||
self.command(&protocol::frame(cmd::SET_MODE, &[mode.byte()]))
|
||||
}
|
||||
|
||||
/// `8C 78 00`: AC voltage, 100-240 V.
|
||||
pub fn set_ac_voltage(&mut self, volts: f32) -> Result<()> {
|
||||
self.command(&protocol::frame_f32(cmd::SET_AC, 0x00, volts))
|
||||
}
|
||||
|
||||
/// `8C 78 01`: AC frequency, 50 or 60 Hz.
|
||||
pub fn set_ac_frequency(&mut self, hz: f32) -> Result<()> {
|
||||
self.command(&protocol::frame_f32(cmd::SET_AC, 0x01, hz))
|
||||
}
|
||||
|
||||
/// `8C 73 01`: DC voltage, 1-60 V.
|
||||
pub fn set_dc_voltage(&mut self, volts: f32) -> Result<()> {
|
||||
self.command(&protocol::frame_f32(cmd::SET_DC, 0x01, volts))
|
||||
}
|
||||
|
||||
/// `8C 73 00`: DC current limit, 0-5 A.
|
||||
pub fn set_dc_current(&mut self, amps: f32) -> Result<()> {
|
||||
self.command(&protocol::frame_f32(cmd::SET_DC, 0x00, amps))
|
||||
}
|
||||
|
||||
/// `8C 01`: integration time in milliseconds, 0 = automatic.
|
||||
pub fn set_integration_time(&mut self, ms: f64) -> Result<()> {
|
||||
let us = (ms * 1000.0) as u32;
|
||||
self.command(&protocol::frame(cmd::INTEGRATION_TIME, &us.to_le_bytes()))
|
||||
}
|
||||
|
||||
/// `8C 79`: the supply settings.
|
||||
pub fn read_psu_settings(&mut self) -> Result<PsuSettings> {
|
||||
let r = self.query(cmd::READ_PSU, PsuSettings::RESPONSE_LEN, ACK_TIMEOUT)?;
|
||||
PsuSettings::parse(&r[2..]).ok_or(Error::Short {
|
||||
what: "PSU settings",
|
||||
got: r.len(),
|
||||
want: 20,
|
||||
})
|
||||
}
|
||||
|
||||
/// Poll every 100 ms until a reading is ready.
|
||||
pub fn wait_for_data(&mut self, timeout: Duration) -> Result<()> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
while Instant::now() < deadline {
|
||||
match self.poll_state() {
|
||||
Ok(s) if s.data_available => return Ok(()),
|
||||
Ok(_) | Err(Error::BadResponse { .. } | Error::Short { .. }) => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
self.pause(Duration::from_millis(100))?;
|
||||
}
|
||||
Err(Error::Timeout)
|
||||
}
|
||||
|
||||
/// Read both blocks; the electrical block is optional.
|
||||
fn read_reading(&mut self) -> Result<Reading> {
|
||||
let meas = self.read_measurement_block()?;
|
||||
let elec = self.read_electrical_block().ok();
|
||||
Ok(Reading::parse(&meas, elec.as_deref()).expect("full measurement block"))
|
||||
}
|
||||
|
||||
/// One reading in continuous mode: start, wait, read, stop. With `psu`
|
||||
/// the supply is switched on first and off afterwards, also on errors.
|
||||
pub fn take_reading(&mut self, psu: bool) -> Result<Reading> {
|
||||
if psu {
|
||||
self.psu_on()?;
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
let r = (|| {
|
||||
self.start_measurement()?;
|
||||
let r = self
|
||||
.wait_for_data(DATA_TIMEOUT)
|
||||
.and_then(|_| self.read_reading());
|
||||
let _ = self.stop_measurement();
|
||||
r
|
||||
})();
|
||||
if psu {
|
||||
let _ = self.psu_off();
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// One reading like the vendor's single test: `8C 0E 02` triggers it,
|
||||
/// poll, read both blocks, then reset. The supply is not touched (call
|
||||
/// [`Hpcs6500::send_test_config`] first to match the vendor flow exactly).
|
||||
pub fn take_single_reading(&mut self) -> Result<Reading> {
|
||||
let _ = self.stop_measurement();
|
||||
self.wait_for_data(DATA_TIMEOUT)?;
|
||||
let r = self.read_reading()?;
|
||||
let _ = self.reset();
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Read the latest data without starting or stopping anything (while the
|
||||
/// vendor software drives the instrument).
|
||||
pub fn read_current(&mut self) -> Result<Reading> {
|
||||
self.read_reading()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use std::fmt::Write;
|
||||
|
||||
/// Everything that can go wrong talking to the instrument.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("serial port: {0}")]
|
||||
Serial(#[from] serialport::Error),
|
||||
#[error("I/O: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
/// A write command was not acknowledged with `8C <cmd>`.
|
||||
#[error("no acknowledge for command 0x{cmd:02X} (got {got})")]
|
||||
NoAck { cmd: u8, got: String },
|
||||
/// A read command answered with an unexpected header.
|
||||
#[error("bad response to command 0x{cmd:02X} (got {got})")]
|
||||
BadResponse { cmd: u8, got: String },
|
||||
/// A data block ended early.
|
||||
#[error("{what} short ({got}/{want} bytes)")]
|
||||
Short {
|
||||
what: &'static str,
|
||||
got: usize,
|
||||
want: usize,
|
||||
},
|
||||
#[error("timeout waiting for measurement data")]
|
||||
Timeout,
|
||||
/// The cancel flag (Ctrl+C) was raised during a wait.
|
||||
#[error("cancelled")]
|
||||
Cancelled,
|
||||
#[error("HPCS 6500 not found (no serial port with VID 0483 / PID 5741)")]
|
||||
NotFound,
|
||||
#[error("pcap: {0}")]
|
||||
Pcap(String),
|
||||
}
|
||||
|
||||
pub type Result<T, E = Error> = std::result::Result<T, E>;
|
||||
|
||||
/// `8c 13 0f` style hex for error messages, `(nothing)` when empty.
|
||||
pub(crate) fn hex(b: &[u8]) -> String {
|
||||
if b.is_empty() {
|
||||
return "nothing".into();
|
||||
}
|
||||
let mut s = String::new();
|
||||
for (i, x) in b.iter().take(24).enumerate() {
|
||||
if i > 0 {
|
||||
s.push(' ');
|
||||
}
|
||||
let _ = write!(s, "{x:02x}");
|
||||
}
|
||||
if b.len() > 24 {
|
||||
let _ = write!(s, " ... ({} bytes)", b.len());
|
||||
}
|
||||
s
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
//! Text output of the command line tool, character for character the same
|
||||
//! as the Python driver's (`format_reading`, `format_quick`, the CSV export
|
||||
//! and the `--parse` report), so scripts reading it keep working.
|
||||
|
||||
use crate::pcap::Message;
|
||||
use crate::pyfmt;
|
||||
use crate::reading::{ELECTRICAL_FIELDS, FIELDS, Reading};
|
||||
use std::fmt::{self, Write};
|
||||
|
||||
/// A float that formats like a Python number (`nan`, right-aligned).
|
||||
struct Py(f64);
|
||||
|
||||
impl fmt::Display for Py {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let s = match f.precision() {
|
||||
Some(p) => pyfmt::fixed(self.0, p),
|
||||
None => pyfmt::repr(self.0),
|
||||
};
|
||||
match f.width() {
|
||||
Some(w) => write!(f, "{s:>w$}"),
|
||||
None => f.write_str(&s),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The full report of one reading. `show_spectrum` adds the harmonics (when
|
||||
/// present) and every 5th spectrum point as a bar chart.
|
||||
pub fn format_reading(r: &Reading, show_spectrum: bool) -> String {
|
||||
let g = |k: &str| Py(r.get(k).unwrap_or(0.0));
|
||||
let mut l: Vec<String> = vec![];
|
||||
l.push(format!(" Device: {}", r.device));
|
||||
l.push(format!(" Date: {} Time: {}", r.test_date, r.test_time));
|
||||
l.push(String::new());
|
||||
|
||||
l.push(" --- Photometric ---".into());
|
||||
l.push(format!(" Phi (lm): {:10.2}", g("Phi_lm")));
|
||||
l.push(format!(" eta (lm/W): {:10.2}", g("eta_lm_W")));
|
||||
l.push(format!(" CCT (K): {:10.0}", g("CCT_K")));
|
||||
l.push(format!(" Duv: {:10.5}", g("Duv")));
|
||||
l.push(String::new());
|
||||
|
||||
l.push(" --- Chromaticity ---".into());
|
||||
l.push(format!(" x: {:.4} y: {:.4}", g("x"), g("y")));
|
||||
l.push(format!(" u: {:.4} v: {:.4}", g("u"), g("v")));
|
||||
l.push(format!(
|
||||
" u': {:.4} v': {:.4}",
|
||||
g("u_prime"),
|
||||
g("v_prime")
|
||||
));
|
||||
l.push(String::new());
|
||||
|
||||
l.push(" --- CRI ---".into());
|
||||
l.push(format!(" Ra: {:.1} SDCM: {:.2}", g("Ra"), g("SDCM")));
|
||||
let ri: Vec<String> = (1..=15)
|
||||
.map(|i| format!("R{i}={:.0}", Py(r.r(i))))
|
||||
.collect();
|
||||
l.push(format!(" {}", ri[..8].join(", ")));
|
||||
l.push(format!(" {}", ri[8..].join(", ")));
|
||||
l.push(String::new());
|
||||
|
||||
l.push(" --- CIE 1931 ---".into());
|
||||
l.push(format!(
|
||||
" X: {:.3} Y: {:.3} Z: {:.3}",
|
||||
g("CIE_X"),
|
||||
g("CIE_Y"),
|
||||
g("CIE_Z")
|
||||
));
|
||||
l.push(format!(" TLCI-2012: {:.0}", g("TLCI")));
|
||||
l.push(String::new());
|
||||
|
||||
l.push(" --- Radiometric ---".into());
|
||||
l.push(format!(" Phi_e (mW): {:10.3}", g("Phi_e_mW")));
|
||||
l.push(format!(" Phi_eb (mW): {:10.3}", g("Phi_eb_mW")));
|
||||
l.push(format!(" Phi_ey (mW): {:10.3}", g("Phi_ey_mW")));
|
||||
l.push(format!(" Phi_er (mW): {:10.3}", g("Phi_er_mW")));
|
||||
l.push(format!(" Phi_efr (mW): {:10.3}", g("Phi_efr_mW")));
|
||||
l.push(String::new());
|
||||
|
||||
l.push(" --- Electrical ---".into());
|
||||
l.push(format!(" Voltage (V): {:10.2}", g("Voltage_V")));
|
||||
l.push(format!(" Current (A): {:10.4}", g("Current_A")));
|
||||
l.push(format!(" Power (W): {:10.3}", g("Power_W")));
|
||||
l.push(format!(" Frequency (Hz):{:10.2}", g("Freq_Hz")));
|
||||
l.push(format!(" Power Factor: {:10.4}", g("PF")));
|
||||
let harmonics = r.electrical.as_ref().and_then(|e| e.harmonics.as_ref());
|
||||
if let Some(h) = harmonics {
|
||||
l.push(format!(" UThd (%): {:10.4}", Py(h.uthd)));
|
||||
l.push(format!(" AThd (%): {:10.4}", Py(h.athd)));
|
||||
}
|
||||
l.push(String::new());
|
||||
|
||||
if let (true, Some(h)) = (show_spectrum, harmonics) {
|
||||
for (title, values) in [("Voltage", &h.voltage), ("Current", &h.current)] {
|
||||
l.push(format!(" --- {title} Harmonics (%) ---"));
|
||||
for (i, v) in values.iter().enumerate() {
|
||||
if *v > 0.001 || i == 0 {
|
||||
l.push(format!(" H{:2}: {:8.4}", i + 1, Py(*v)));
|
||||
}
|
||||
}
|
||||
l.push(String::new());
|
||||
}
|
||||
}
|
||||
|
||||
l.push(" --- Sensor ---".into());
|
||||
l.push(format!(" Peak Signal: {:10.0}", g("PeakSignal")));
|
||||
l.push(format!(" Dark Signal: {:10.0}", g("DarkSignal")));
|
||||
l.push(format!(" Compensate: {:10.0}", g("Compensate")));
|
||||
|
||||
if show_spectrum {
|
||||
l.push(String::new());
|
||||
l.push(" --- Spectrum (380-1050nm) ---".into());
|
||||
let peak = r.spectrum.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
|
||||
let peak = if r.spectrum.is_empty() { 1.0 } else { peak };
|
||||
for (i, (wl, v)) in r.spectrum_nm().iter().zip(&r.spectrum).enumerate() {
|
||||
if i % 5 == 0 {
|
||||
let norm = if peak > 0.0 { v / peak } else { 0.0 };
|
||||
let bar = "#".repeat((norm * 40.0).max(0.0) as usize);
|
||||
l.push(format!(" {:7.1}nm: {:8.4} [{bar}]", Py(*wl), Py(*v)));
|
||||
}
|
||||
}
|
||||
}
|
||||
l.join("\n")
|
||||
}
|
||||
|
||||
/// ` #3 479.6 lm 5653 K` (lumen and CCT only).
|
||||
pub fn format_quick(r: &Reading, reading_num: Option<usize>) -> String {
|
||||
let prefix = match reading_num {
|
||||
Some(n) if n > 0 => format!("#{n} "),
|
||||
_ => String::new(),
|
||||
};
|
||||
format!(
|
||||
" {prefix}{:.1} lm {:.0} K",
|
||||
Py(r.flux_lm()),
|
||||
Py(r.cct_k())
|
||||
)
|
||||
}
|
||||
|
||||
/// CSV columns: `timestamp`, the measurement fields, the electrical fields.
|
||||
pub fn csv_columns() -> Vec<&'static str> {
|
||||
std::iter::once("timestamp")
|
||||
.chain(FIELDS.iter().map(|f| f.0))
|
||||
.chain(ELECTRICAL_FIELDS.iter().map(|f| f.0))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The CSV header line (CRLF, like Python's csv module).
|
||||
pub fn csv_header() -> String {
|
||||
csv_columns().join(",") + "\r\n"
|
||||
}
|
||||
|
||||
/// One CSV row; numbers as Python writes them, empty cells for values the
|
||||
/// reading does not have.
|
||||
pub fn csv_row(timestamp: &str, r: &Reading) -> String {
|
||||
let mut cells = vec![timestamp.to_string()];
|
||||
for c in &csv_columns()[1..] {
|
||||
cells.push(r.get(c).map(pyfmt::repr).unwrap_or_default());
|
||||
}
|
||||
cells.join(",") + "\r\n"
|
||||
}
|
||||
|
||||
/// The `--parse` report of a capture, as the Python tool printed it.
|
||||
pub fn pcap_report(messages: &[Message], quick: bool, show_spectrum: bool) -> String {
|
||||
let mut out = String::new();
|
||||
for (i, b) in crate::pcap::blocks(messages).iter().enumerate() {
|
||||
let Some(r) = Reading::parse(&b.measurement, b.electrical.as_deref()) else {
|
||||
continue;
|
||||
};
|
||||
if quick {
|
||||
let _ = writeln!(out, "{}", format_quick(&r, Some(i + 1)));
|
||||
} else {
|
||||
let _ = writeln!(out, "\n{}", "=".repeat(60));
|
||||
let _ = writeln!(out, "Reading {} (t={:.2}s)", i + 1, b.ts);
|
||||
let _ = writeln!(out, "{}", "=".repeat(60));
|
||||
let _ = writeln!(out, "{}", format_reading(&r, show_spectrum));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
//! Driver for the HPCS 6500 spectrophotometer / integrating sphere.
|
||||
//!
|
||||
//! The instrument talks a small binary protocol over its USB virtual COM
|
||||
//! port (STM32 VCP, VID 0483 / PID 5741, 115200 baud). The protocol was
|
||||
//! reverse-engineered from USB captures of the vendor software and is
|
||||
//! documented byte by byte in `PROTOCOL.md`.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use hpcs6500::{Hpcs6500, Mode};
|
||||
//!
|
||||
//! let port = hpcs6500::find_port().expect("HPCS 6500 not connected");
|
||||
//! let mut dev = Hpcs6500::open(&port)?;
|
||||
//! println!("{}", dev.identify()?);
|
||||
//! dev.set_mode(Mode::Ac)?;
|
||||
//! dev.set_ac_voltage(230.0)?;
|
||||
//! dev.psu_on()?;
|
||||
//! let r = dev.take_single_reading()?;
|
||||
//! dev.psu_off()?;
|
||||
//! println!("{:.0} lm, {:.0} K, Ra {:.1}", r.flux_lm(), r.cct_k(), r.ra());
|
||||
//! # Ok::<(), hpcs6500::Error>(())
|
||||
//! ```
|
||||
//!
|
||||
//! Without hardware, [`sim::Simulator`] answers like the instrument, either
|
||||
//! with readings replayed from a USB capture or with built-in sample data.
|
||||
|
||||
mod device;
|
||||
mod error;
|
||||
pub mod format;
|
||||
pub mod pcap;
|
||||
pub mod protocol;
|
||||
pub mod pyfmt;
|
||||
pub mod reading;
|
||||
pub mod sim;
|
||||
|
||||
pub use device::{Hpcs6500, PortInfo, SerialTransport, Transport, find_port, list_ports};
|
||||
pub use error::{Error, Result};
|
||||
pub use protocol::{Mode, PollState, PsuSettings};
|
||||
pub use reading::{Electrical, Harmonics, Reading};
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
//! USB captures (USBPcap pcap files) of the vendor software talking to the
|
||||
//! instrument: the source of the protocol, and test data for the parser and
|
||||
//! the simulator.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::protocol::{ELECTRICAL_LEN, MEASUREMENT_LEN};
|
||||
use crate::reading::Reading;
|
||||
use std::path::Path;
|
||||
|
||||
/// Direction of a USB bulk transfer.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Dir {
|
||||
/// Host to instrument (OUT endpoint).
|
||||
Tx,
|
||||
/// Instrument to host (IN endpoint).
|
||||
Rx,
|
||||
}
|
||||
|
||||
/// One USB packet with a payload.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Packet {
|
||||
/// 1-based index in the file, counting every record.
|
||||
pub num: usize,
|
||||
/// Seconds since the epoch.
|
||||
pub ts: f64,
|
||||
pub status: u32,
|
||||
/// USBPcap `info` byte: bit 0 set = IN (device to host).
|
||||
pub info: u8,
|
||||
pub device: u16,
|
||||
/// Endpoint address; bit 7 set = IN.
|
||||
pub endpoint: u8,
|
||||
/// 0 isochronous, 1 interrupt, 2 control, 3 bulk.
|
||||
pub transfer: u8,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Packet {
|
||||
pub fn is_bulk(&self) -> bool {
|
||||
self.transfer == 3
|
||||
}
|
||||
/// Direction from the endpoint address, as the driver's parser uses it.
|
||||
pub fn dir(&self) -> Dir {
|
||||
if self.endpoint & 0x80 != 0 {
|
||||
Dir::Rx
|
||||
} else {
|
||||
Dir::Tx
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// File header facts.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Header {
|
||||
pub version: (u16, u16),
|
||||
pub snaplen: u32,
|
||||
/// 249 = USBPcap.
|
||||
pub link_type: u32,
|
||||
}
|
||||
|
||||
/// A bulk transfer payload, timestamp relative to the first one.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Message {
|
||||
pub ts: f64,
|
||||
pub dir: Dir,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Every record that carries a payload after its USBPcap header.
|
||||
pub fn packets(data: &[u8]) -> Result<(Header, Vec<Packet>)> {
|
||||
if data.len() < 24 {
|
||||
return Err(Error::Pcap(format!(
|
||||
"file too small for a pcap header ({} bytes)",
|
||||
data.len()
|
||||
)));
|
||||
}
|
||||
let magic = u32::from_le_bytes(data[..4].try_into().expect("4 bytes"));
|
||||
let (big, nanos) = match magic {
|
||||
0xA1B2_C3D4 => (false, false),
|
||||
0xA1B2_3C4D => (false, true),
|
||||
0xD4C3_B2A1 => (true, false),
|
||||
0x4D3C_B2A1 => (true, true),
|
||||
m => return Err(Error::Pcap(format!("not a pcap file (magic {m:#010x})"))),
|
||||
};
|
||||
let u16_at = |o: usize| {
|
||||
let b = [data[o], data[o + 1]];
|
||||
if big {
|
||||
u16::from_be_bytes(b)
|
||||
} else {
|
||||
u16::from_le_bytes(b)
|
||||
}
|
||||
};
|
||||
let u32_at = |o: usize| {
|
||||
let b: [u8; 4] = data[o..o + 4].try_into().expect("4 bytes");
|
||||
if big {
|
||||
u32::from_be_bytes(b)
|
||||
} else {
|
||||
u32::from_le_bytes(b)
|
||||
}
|
||||
};
|
||||
let header = Header {
|
||||
version: (u16_at(4), u16_at(6)),
|
||||
snaplen: u32_at(16),
|
||||
link_type: u32_at(20),
|
||||
};
|
||||
let mut out = vec![];
|
||||
let mut off = 24;
|
||||
let mut num = 0;
|
||||
while off + 16 <= data.len() {
|
||||
let (ts_sec, ts_frac, incl) = (u32_at(off), u32_at(off + 4), u32_at(off + 8) as usize);
|
||||
off += 16;
|
||||
if off + incl > data.len() {
|
||||
break;
|
||||
}
|
||||
let p = &data[off..off + incl];
|
||||
off += incl;
|
||||
num += 1;
|
||||
if p.len() < 27 {
|
||||
continue;
|
||||
}
|
||||
// the USBPcap header itself is always little-endian
|
||||
let hdr_len = u16::from_le_bytes([p[0], p[1]]) as usize;
|
||||
if hdr_len >= p.len() {
|
||||
continue;
|
||||
}
|
||||
out.push(Packet {
|
||||
num,
|
||||
ts: ts_sec as f64 + ts_frac as f64 / if nanos { 1e9 } else { 1e6 },
|
||||
status: u32::from_le_bytes(p[10..14].try_into().expect("4 bytes")),
|
||||
info: p[16],
|
||||
device: u16::from_le_bytes([p[19], p[20]]),
|
||||
endpoint: p[21],
|
||||
transfer: p[22],
|
||||
data: p[hdr_len..].to_vec(),
|
||||
});
|
||||
}
|
||||
Ok((header, out))
|
||||
}
|
||||
|
||||
/// The bulk transfers in order, timestamps relative to the first.
|
||||
pub fn messages(data: &[u8]) -> Result<Vec<Message>> {
|
||||
let (_, pk) = packets(data)?;
|
||||
let first = pk.iter().find(|p| p.is_bulk()).map(|p| p.ts).unwrap_or(0.0);
|
||||
Ok(pk
|
||||
.into_iter()
|
||||
.filter(|p| p.is_bulk())
|
||||
.map(|p| Message {
|
||||
ts: p.ts - first,
|
||||
dir: p.dir(),
|
||||
data: p.data,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn read_messages(path: impl AsRef<Path>) -> Result<Vec<Message>> {
|
||||
let path = path.as_ref();
|
||||
let data = std::fs::read(path).map_err(|e| Error::Pcap(format!("{}: {e}", path.display())))?;
|
||||
messages(&data)
|
||||
}
|
||||
|
||||
/// A measurement block (with its time) and the electrical block at the same index.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Block {
|
||||
pub ts: f64,
|
||||
pub measurement: Vec<u8>,
|
||||
pub electrical: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Measurement blocks (3904 bytes starting "HPCS6500") paired by index with
|
||||
/// electrical blocks (1584 bytes), as the vendor software reads them.
|
||||
pub fn blocks(messages: &[Message]) -> Vec<Block> {
|
||||
let rx = messages.iter().filter(|m| m.dir == Dir::Rx);
|
||||
let meas: Vec<&Message> = rx
|
||||
.clone()
|
||||
.filter(|m| m.data.len() == MEASUREMENT_LEN && m.data.starts_with(b"HPCS6500"))
|
||||
.collect();
|
||||
let elec: Vec<&Message> = rx.filter(|m| m.data.len() == ELECTRICAL_LEN).collect();
|
||||
meas.iter()
|
||||
.enumerate()
|
||||
.map(|(i, m)| Block {
|
||||
ts: m.ts,
|
||||
measurement: m.data.clone(),
|
||||
electrical: elec.get(i).map(|e| e.data.clone()),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The readings in a capture.
|
||||
pub fn readings(messages: &[Message]) -> Vec<Reading> {
|
||||
blocks(messages)
|
||||
.iter()
|
||||
.filter_map(|b| Reading::parse(&b.measurement, b.electrical.as_deref()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A pcap file in memory, for tests and tools: one record per payload.
|
||||
pub fn write(records: &[(Dir, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut f = vec![];
|
||||
f.extend_from_slice(&0xA1B2_C3D4u32.to_le_bytes());
|
||||
f.extend_from_slice(&2u16.to_le_bytes());
|
||||
f.extend_from_slice(&4u16.to_le_bytes());
|
||||
f.extend_from_slice(&[0; 8]);
|
||||
f.extend_from_slice(&65535u32.to_le_bytes());
|
||||
f.extend_from_slice(&249u32.to_le_bytes());
|
||||
for (i, (dir, data)) in records.iter().enumerate() {
|
||||
let mut p = vec![0u8; 27];
|
||||
p[..2].copy_from_slice(&27u16.to_le_bytes());
|
||||
p[16] = matches!(dir, Dir::Rx) as u8;
|
||||
p[19..21].copy_from_slice(&4u16.to_le_bytes());
|
||||
p[21] = if matches!(dir, Dir::Rx) { 0x81 } else { 0x01 };
|
||||
p[22] = 3;
|
||||
p[23..27].copy_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
p.extend_from_slice(data);
|
||||
f.extend_from_slice(&1_770_000_000u32.to_le_bytes());
|
||||
f.extend_from_slice(&(i as u32 * 1000).to_le_bytes());
|
||||
f.extend_from_slice(&(p.len() as u32).to_le_bytes());
|
||||
f.extend_from_slice(&(p.len() as u32).to_le_bytes());
|
||||
f.extend_from_slice(&p);
|
||||
}
|
||||
f
|
||||
}
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
//! Frame layout, command bytes and the small fixed-size responses.
|
||||
//! See `PROTOCOL.md` for the full reference.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Every frame in both directions starts with this byte.
|
||||
pub const SYNC: u8 = 0x8C;
|
||||
pub const BAUD: u32 = 115_200;
|
||||
/// USB IDs of the instrument's virtual COM port (STM32 VCP).
|
||||
pub const USB_VID: u16 = 0x0483;
|
||||
pub const USB_PID: u16 = 0x5741;
|
||||
|
||||
/// Command bytes (second byte of every frame).
|
||||
pub mod cmd {
|
||||
pub const IDENTIFY: u8 = 0x00;
|
||||
pub const INTEGRATION_TIME: u8 = 0x01;
|
||||
pub const POLL_STATE: u8 = 0x03;
|
||||
pub const STATUS: u8 = 0x05;
|
||||
pub const START: u8 = 0x0E;
|
||||
pub const READ_MEASUREMENT: u8 = 0x13;
|
||||
pub const RESET: u8 = 0x25;
|
||||
pub const CONFIG: u8 = 0x2A;
|
||||
pub const TEST_CONFIG: u8 = 0x2B;
|
||||
pub const PSU_OUTPUT: u8 = 0x72;
|
||||
pub const SET_DC: u8 = 0x73;
|
||||
pub const READ_ELECTRICAL: u8 = 0x77;
|
||||
pub const SET_AC: u8 = 0x78;
|
||||
pub const READ_PSU: u8 = 0x79;
|
||||
pub const SET_MODE: u8 = 0x7A;
|
||||
}
|
||||
|
||||
/// Payload length of the measurement block (`8C 13`, after a 4-byte header).
|
||||
pub const MEASUREMENT_LEN: usize = 3904;
|
||||
/// Payload length of the electrical / harmonics block (`8C 77`, after a 4-byte header).
|
||||
pub const ELECTRICAL_LEN: usize = 1584;
|
||||
/// Transport headers the instrument sends in front of the two blocks.
|
||||
pub const MEASUREMENT_HEADER: [u8; 4] = [SYNC, cmd::READ_MEASUREMENT, 0x0F, 0x40];
|
||||
pub const ELECTRICAL_HEADER: [u8; 4] = [SYNC, cmd::READ_ELECTRICAL, 0x06, 0x30];
|
||||
|
||||
/// Poll response byte 5.
|
||||
pub const STATE_IDLE: u8 = 0x04;
|
||||
pub const STATE_MEASURING: u8 = 0x01;
|
||||
|
||||
/// `8C 0E` sub commands.
|
||||
pub const START_CONTINUOUS: u8 = 0x01;
|
||||
/// Stops a continuous measurement; on an idle instrument it triggers one reading.
|
||||
pub const STOP_OR_SINGLE: u8 = 0x02;
|
||||
|
||||
/// Single-test parameters (`8C 2B` sub 00), captured from the vendor software.
|
||||
const TEST_CONFIG_SUB0_HEX: &str = concat!(
|
||||
"00003c00000001008813037c011a04000000484200000208",
|
||||
"000000000001060000000000803f0000803f0000803f0000",
|
||||
"803f0000803f00000000003f",
|
||||
);
|
||||
/// Single-test config (`8C 2B` sub 01); byte 37 is the auto-PSU flag.
|
||||
const TEST_CONFIG_SUB1_HEX: &str = concat!(
|
||||
"017c010c037c010c037c010c030000803f000000000000000000000000",
|
||||
"00000000000000000001000000400000803f",
|
||||
);
|
||||
pub const AUTO_PSU_BYTE: usize = 37;
|
||||
|
||||
fn unhex(s: &str) -> Vec<u8> {
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("valid hex"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A frame: `8C <cmd> <payload...>`.
|
||||
pub fn frame(cmd: u8, payload: &[u8]) -> Vec<u8> {
|
||||
let mut f = Vec::with_capacity(2 + payload.len());
|
||||
f.push(SYNC);
|
||||
f.push(cmd);
|
||||
f.extend_from_slice(payload);
|
||||
f
|
||||
}
|
||||
|
||||
/// `8C <cmd> <sub> <f32 LE>` (AC and DC parameters).
|
||||
pub fn frame_f32(cmd: u8, sub: u8, value: f32) -> Vec<u8> {
|
||||
let mut p = vec![sub];
|
||||
p.extend_from_slice(&value.to_le_bytes());
|
||||
frame(cmd, &p)
|
||||
}
|
||||
|
||||
/// The two `8C 2B` frames of the vendor's single-test configuration.
|
||||
pub fn test_config_frames(auto_psu: bool) -> [Vec<u8>; 2] {
|
||||
let mut sub1 = unhex(TEST_CONFIG_SUB1_HEX);
|
||||
if auto_psu {
|
||||
sub1[AUTO_PSU_BYTE] = 0x01;
|
||||
}
|
||||
[
|
||||
frame(cmd::TEST_CONFIG, &unhex(TEST_CONFIG_SUB0_HEX)),
|
||||
frame(cmd::TEST_CONFIG, &sub1),
|
||||
]
|
||||
}
|
||||
|
||||
/// Output mode of the built-in supply.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub enum Mode {
|
||||
#[serde(rename = "AC")]
|
||||
Ac,
|
||||
#[serde(rename = "DC")]
|
||||
Dc,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
pub fn byte(self) -> u8 {
|
||||
match self {
|
||||
Mode::Ac => 0x00,
|
||||
Mode::Dc => 0x01,
|
||||
}
|
||||
}
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Mode::Ac => "AC",
|
||||
Mode::Dc => "DC",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Mode {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for Mode {
|
||||
type Err = String;
|
||||
fn from_str(s: &str) -> Result<Self, String> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"ac" => Ok(Mode::Ac),
|
||||
"dc" => Ok(Mode::Dc),
|
||||
_ => Err(format!("mode must be ac or dc, not {s:?}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings of the built-in supply (`8C 79`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
|
||||
pub struct PsuSettings {
|
||||
pub ac_voltage: f64,
|
||||
pub ac_frequency: f64,
|
||||
pub dc_voltage: f64,
|
||||
pub dc_current: f64,
|
||||
pub mode: Mode,
|
||||
}
|
||||
|
||||
impl PsuSettings {
|
||||
/// Response length including the `8C 79` header.
|
||||
pub const RESPONSE_LEN: usize = 20;
|
||||
|
||||
/// Parse the 18 bytes after the header.
|
||||
pub fn parse(p: &[u8]) -> Option<Self> {
|
||||
if p.len() < 17 {
|
||||
return None;
|
||||
}
|
||||
let f = |o: usize| f32::from_le_bytes([p[o], p[o + 1], p[o + 2], p[o + 3]]) as f64;
|
||||
Some(PsuSettings {
|
||||
ac_voltage: f(0),
|
||||
ac_frequency: f(4),
|
||||
dc_voltage: f(8),
|
||||
dc_current: f(12),
|
||||
mode: if p[16] == 1 { Mode::Dc } else { Mode::Ac },
|
||||
})
|
||||
}
|
||||
|
||||
/// The full response frame the instrument sends.
|
||||
pub fn to_response(&self) -> Vec<u8> {
|
||||
let mut v = vec![SYNC, cmd::READ_PSU];
|
||||
for x in [
|
||||
self.ac_voltage,
|
||||
self.ac_frequency,
|
||||
self.dc_voltage,
|
||||
self.dc_current,
|
||||
] {
|
||||
v.extend_from_slice(&(x as f32).to_le_bytes());
|
||||
}
|
||||
v.push(self.mode.byte());
|
||||
v.push(0xFF);
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
/// Answer to `8C 03`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub struct PollState {
|
||||
/// A new reading can be read with `8C 13` / `8C 77`.
|
||||
pub data_available: bool,
|
||||
/// [`STATE_IDLE`] or [`STATE_MEASURING`].
|
||||
pub state: u8,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_config_matches_vendor_capture() {
|
||||
let [a, b] = test_config_frames(false);
|
||||
assert_eq!(a.len(), 2 + 60);
|
||||
assert_eq!(b.len(), 2 + 47);
|
||||
assert_eq!(&a[..4], &[0x8C, 0x2B, 0x00, 0x00]);
|
||||
assert_eq!(b[2], 0x01);
|
||||
assert_eq!(b[2 + AUTO_PSU_BYTE], 0x00);
|
||||
let [_, b] = test_config_frames(true);
|
||||
assert_eq!(b[2 + AUTO_PSU_BYTE], 0x01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psu_settings_round_trip() {
|
||||
let s = PsuSettings {
|
||||
ac_voltage: 230.0,
|
||||
ac_frequency: 50.0,
|
||||
dc_voltage: 24.0,
|
||||
dc_current: 5.0,
|
||||
mode: Mode::Ac,
|
||||
};
|
||||
let r = s.to_response();
|
||||
assert_eq!(r.len(), PsuSettings::RESPONSE_LEN);
|
||||
assert_eq!(PsuSettings::parse(&r[2..]), Some(s));
|
||||
assert_eq!(
|
||||
frame_f32(cmd::SET_AC, 0, 230.0),
|
||||
vec![0x8C, 0x78, 0x00, 0x00, 0x00, 0x66, 0x43]
|
||||
);
|
||||
}
|
||||
}
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
//! Number formatting that matches Python, so files written by the Rust tools
|
||||
//! are byte-identical to the ones the Python tools wrote (CSV exports, lamp
|
||||
//! bundles): `repr(float)`, `format(x, ".6g")`, `format(x, ".2f")` and
|
||||
//! `round(x, n)`.
|
||||
//!
|
||||
//! Python rounds the exact binary value half to even (David Gay's dtoa);
|
||||
//! Rust's shortest formatting breaks ties differently (`93.72439575195313`
|
||||
//! vs Python's `93.72439575195312`), so the digits are derived here from the
|
||||
//! exact decimal expansion.
|
||||
|
||||
/// Python `repr(x)` / `str(x)` of a float: shortest round-trip digits,
|
||||
/// exponent form below 1e-4 and from 1e16 (`1e-05`, `1e+16`), always a
|
||||
/// `.0` on whole numbers.
|
||||
pub fn repr(x: f64) -> String {
|
||||
if let Some(s) = special(x) {
|
||||
return s;
|
||||
}
|
||||
if x == 0.0 {
|
||||
return if x.is_sign_negative() {
|
||||
"-0.0".into()
|
||||
} else {
|
||||
"0.0".into()
|
||||
};
|
||||
}
|
||||
let (neg, digits, exp) = exact(x);
|
||||
let (mut d, mut e) = (digits.clone(), exp);
|
||||
for p in 1..=17 {
|
||||
let (rd, re) = round_sig(&digits, exp, p);
|
||||
if to_f64(&rd, re) == x.abs() {
|
||||
(d, e) = (rd, re);
|
||||
break;
|
||||
}
|
||||
}
|
||||
trim(&mut d);
|
||||
let mut out = String::with_capacity(24);
|
||||
if neg {
|
||||
out.push('-');
|
||||
}
|
||||
if (-4..16).contains(&e) {
|
||||
place(&mut out, &d, e, 0);
|
||||
if !out.contains('.') {
|
||||
out.push_str(".0");
|
||||
}
|
||||
} else {
|
||||
sci(&mut out, &d, e);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Python `format(x, f".{prec}g")`.
|
||||
pub fn g(x: f64, prec: usize) -> String {
|
||||
if let Some(s) = special(x) {
|
||||
return s;
|
||||
}
|
||||
let p = prec.max(1);
|
||||
if x == 0.0 {
|
||||
return if x.is_sign_negative() {
|
||||
"-0".into()
|
||||
} else {
|
||||
"0".into()
|
||||
};
|
||||
}
|
||||
let (neg, digits, exp) = exact(x);
|
||||
let (mut d, e) = round_sig(&digits, exp, p);
|
||||
trim(&mut d);
|
||||
let mut out = String::new();
|
||||
if neg {
|
||||
out.push('-');
|
||||
}
|
||||
if e >= -4 && e < p as i32 {
|
||||
place(&mut out, &d, e, 0);
|
||||
} else {
|
||||
sci(&mut out, &d, e);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Python `format(x, f".{prec}f")`.
|
||||
pub fn fixed(x: f64, prec: usize) -> String {
|
||||
if let Some(s) = special(x) {
|
||||
return s;
|
||||
}
|
||||
let mut out = String::new();
|
||||
if x.is_sign_negative() {
|
||||
out.push('-');
|
||||
}
|
||||
if x == 0.0 {
|
||||
place(&mut out, b"0", 0, prec);
|
||||
return out;
|
||||
}
|
||||
let (_, digits, exp) = exact(x);
|
||||
// significant digits kept when rounding at the prec-th decimal
|
||||
let keep = exp + 1 + prec as i32;
|
||||
let (d, e) = if keep > 0 {
|
||||
round_sig(&digits, exp, keep as usize)
|
||||
} else if keep == 0 && round_up_from(&digits, 0, b'0') {
|
||||
(b"1".to_vec(), -(prec as i32))
|
||||
} else {
|
||||
(b"0".to_vec(), 0)
|
||||
};
|
||||
place(&mut out, &d, e, prec);
|
||||
out
|
||||
}
|
||||
|
||||
/// Python `round(x, ndigits)` (half to even on the exact value).
|
||||
pub fn round(x: f64, ndigits: usize) -> f64 {
|
||||
if !x.is_finite() {
|
||||
return x;
|
||||
}
|
||||
fixed(x, ndigits).parse().unwrap_or(x)
|
||||
}
|
||||
|
||||
fn special(x: f64) -> Option<String> {
|
||||
if x.is_nan() {
|
||||
Some("nan".into())
|
||||
} else if x.is_infinite() {
|
||||
Some(if x > 0.0 { "inf".into() } else { "-inf".into() })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact decimal expansion of a finite non-zero x: (negative, significant
|
||||
/// digits without trailing zeros, decimal exponent of the first digit).
|
||||
fn exact(x: f64) -> (bool, Vec<u8>, i32) {
|
||||
// every f64 has at most 767 significant decimal digits; Rust prints them exactly
|
||||
let s = format!("{:.800e}", x.abs());
|
||||
let (mant, e) = s.split_once('e').expect("exponent");
|
||||
let mut d: Vec<u8> = mant.bytes().filter(u8::is_ascii_digit).collect();
|
||||
trim(&mut d);
|
||||
(x.is_sign_negative(), d, e.parse().expect("exponent"))
|
||||
}
|
||||
|
||||
fn trim(d: &mut Vec<u8>) {
|
||||
while d.len() > 1 && d.last() == Some(&b'0') {
|
||||
d.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Does the tail starting at `from` round up: more than half, or exactly
|
||||
/// half after an odd last kept digit?
|
||||
fn round_up_from(d: &[u8], from: usize, last_kept: u8) -> bool {
|
||||
let Some(&first) = d.get(from) else {
|
||||
return false;
|
||||
};
|
||||
if first != b'5' {
|
||||
return first > b'5';
|
||||
}
|
||||
if d[from + 1..].iter().any(|&c| c != b'0') {
|
||||
return true;
|
||||
}
|
||||
(last_kept - b'0') % 2 == 1
|
||||
}
|
||||
|
||||
/// Round to `n` significant digits, half to even.
|
||||
fn round_sig(d: &[u8], exp: i32, n: usize) -> (Vec<u8>, i32) {
|
||||
if d.len() <= n {
|
||||
return (d.to_vec(), exp);
|
||||
}
|
||||
let mut k = d[..n].to_vec();
|
||||
if round_up_from(d, n, k[n - 1]) {
|
||||
let mut i = n;
|
||||
loop {
|
||||
if i == 0 {
|
||||
// 99.9 -> 100: one more digit in front, one fewer at the end
|
||||
k.insert(0, b'1');
|
||||
k.pop();
|
||||
return (k, exp + 1);
|
||||
}
|
||||
i -= 1;
|
||||
if k[i] == b'9' {
|
||||
k[i] = b'0';
|
||||
} else {
|
||||
k[i] += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
(k, exp)
|
||||
}
|
||||
|
||||
fn to_f64(d: &[u8], e: i32) -> f64 {
|
||||
let s = format!(
|
||||
"{}e{}",
|
||||
std::str::from_utf8(d).expect("digits"),
|
||||
e - d.len() as i32 + 1
|
||||
);
|
||||
s.parse().expect("number")
|
||||
}
|
||||
|
||||
/// Positional notation of digits `d` (first digit at 10^e) with at least
|
||||
/// `min_decimals` decimals.
|
||||
fn place(out: &mut String, d: &[u8], e: i32, min_decimals: usize) {
|
||||
// digit at position 10^i
|
||||
let digit = |i: i32| -> char {
|
||||
let idx = e - i;
|
||||
if idx >= 0 && (idx as usize) < d.len() {
|
||||
d[idx as usize] as char
|
||||
} else {
|
||||
'0'
|
||||
}
|
||||
};
|
||||
for i in (0..=e.max(0)).rev() {
|
||||
out.push(digit(i));
|
||||
}
|
||||
let lowest = e - d.len() as i32 + 1;
|
||||
let decimals = (-lowest).max(min_decimals as i32);
|
||||
if decimals > 0 {
|
||||
out.push('.');
|
||||
for i in 1..=decimals {
|
||||
out.push(digit(-i));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sci(out: &mut String, d: &[u8], e: i32) {
|
||||
out.push(d[0] as char);
|
||||
if d.len() > 1 {
|
||||
out.push('.');
|
||||
out.push_str(std::str::from_utf8(&d[1..]).expect("digits"));
|
||||
}
|
||||
out.push('e');
|
||||
out.push(if e < 0 { '-' } else { '+' });
|
||||
let a = e.unsigned_abs();
|
||||
if a < 10 {
|
||||
out.push('0');
|
||||
}
|
||||
out.push_str(&a.to_string());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::excessive_precision)]
|
||||
fn examples() {
|
||||
assert_eq!(repr(1.0), "1.0");
|
||||
assert_eq!(repr(100.0), "100.0");
|
||||
assert_eq!(repr(0.1 + 0.2), "0.30000000000000004");
|
||||
assert_eq!(repr(1e-5), "1e-05");
|
||||
assert_eq!(repr(0.0001), "0.0001");
|
||||
assert_eq!(repr(1e16), "1e+16");
|
||||
assert_eq!(repr(1234567890123456.0), "1234567890123456.0");
|
||||
assert_eq!(repr(93.724395751953125), "93.72439575195312");
|
||||
assert_eq!(repr(-617.35406494140625), "-617.3540649414062");
|
||||
assert_eq!(g(0.00191776, 6), "0.00191776");
|
||||
assert_eq!(g(1.5e-5, 6), "1.5e-05");
|
||||
assert_eq!(g(123456.5, 6), "123456");
|
||||
assert_eq!(g(1234567.0, 6), "1.23457e+06");
|
||||
assert_eq!(g(999999.5, 6), "1e+06");
|
||||
assert_eq!(g(100.0, 6), "100");
|
||||
assert_eq!(g(0.0, 6), "0");
|
||||
assert_eq!(g(f64::NAN, 6), "nan");
|
||||
assert_eq!(fixed(380.0, 2), "380.00");
|
||||
assert_eq!(fixed(0.125, 2), "0.12");
|
||||
assert_eq!(fixed(0.375, 2), "0.38");
|
||||
assert_eq!(fixed(2.5, 0), "2");
|
||||
assert_eq!(fixed(0.5, 0), "0");
|
||||
assert_eq!(fixed(1.5, 0), "2");
|
||||
assert_eq!(fixed(-0.001, 2), "-0.00");
|
||||
assert_eq!(fixed(0.006, 2), "0.01");
|
||||
assert_eq!(fixed(99.995, 2), "100.00");
|
||||
assert_eq!(fixed(0.004, 2), "0.00");
|
||||
assert_eq!(round(83.25, 1), 83.2);
|
||||
}
|
||||
}
|
||||
+334
@@ -0,0 +1,334 @@
|
||||
//! The two data blocks and what is in them.
|
||||
|
||||
use crate::protocol::MEASUREMENT_LEN;
|
||||
use serde::ser::{Serialize, SerializeMap, Serializer};
|
||||
|
||||
pub const SPECTRUM_START_NM: f64 = 380.0;
|
||||
pub const SPECTRUM_END_NM: f64 = 1050.0;
|
||||
pub const SPECTRUM_OFFSET: usize = 432;
|
||||
pub const SPECTRUM_COUNT: usize = 350;
|
||||
/// (1050 - 380) / (350 - 1) = 1.9198 nm.
|
||||
pub const SPECTRUM_STEP: f64 =
|
||||
(SPECTRUM_END_NM - SPECTRUM_START_NM) / (SPECTRUM_COUNT as f64 - 1.0);
|
||||
|
||||
/// Named float32 fields of the measurement block: (name, offset).
|
||||
/// The names are the column names of the CSV export and the keys of the
|
||||
/// lamp bundles, so they stay as they were in the Python driver.
|
||||
pub const FIELDS: [(&str, usize); 42] = [
|
||||
("Phi_lm", 36),
|
||||
("eta_lm_W", 40),
|
||||
("CCT_K", 44),
|
||||
("Duv", 48),
|
||||
("x", 52),
|
||||
("y", 56),
|
||||
("u", 60),
|
||||
("v", 64),
|
||||
("u_prime", 68),
|
||||
("v_prime", 72),
|
||||
("SDCM", 76),
|
||||
("Ra", 80),
|
||||
("R1", 84),
|
||||
("R2", 88),
|
||||
("R3", 92),
|
||||
("R4", 96),
|
||||
("R5", 100),
|
||||
("R6", 104),
|
||||
("R7", 108),
|
||||
("R8", 112),
|
||||
("R9", 116),
|
||||
("R10", 120),
|
||||
("R11", 124),
|
||||
("R12", 128),
|
||||
("R13", 132),
|
||||
("R14", 136),
|
||||
("R15", 140),
|
||||
("Phi_e_mW", 144),
|
||||
("Phi_euv_mW", 148),
|
||||
("Phi_eb_mW", 152),
|
||||
("Phi_ey_mW", 156),
|
||||
("Phi_er_mW", 160),
|
||||
("Phi_efr_mW", 164),
|
||||
("Phi_eir_mW", 168),
|
||||
("Phi_e_total", 172),
|
||||
("CIE_X", 224),
|
||||
("CIE_Y", 228),
|
||||
("CIE_Z", 232),
|
||||
("TLCI", 236),
|
||||
("PeakSignal", 244),
|
||||
("DarkSignal", 248),
|
||||
("Compensate", 252),
|
||||
];
|
||||
pub const FIELD_COUNT: usize = FIELDS.len();
|
||||
|
||||
/// Electrical block fields: (name, offset).
|
||||
pub const ELECTRICAL_FIELDS: [(&str, usize); 5] = [
|
||||
("Voltage_V", 8),
|
||||
("Current_A", 12),
|
||||
("Power_W", 16),
|
||||
("Freq_Hz", 20),
|
||||
("PF", 24),
|
||||
];
|
||||
|
||||
pub const HARMONICS_V_OFFSET: usize = 544;
|
||||
pub const HARMONICS_COUNT: usize = 50;
|
||||
pub const UTHD_OFFSET: usize = 744;
|
||||
pub const HARMONICS_I_OFFSET: usize = 800;
|
||||
pub const ATHD_OFFSET: usize = 1000;
|
||||
pub const WAVEFORM_V_OFFSET: usize = 30;
|
||||
pub const WAVEFORM_I_OFFSET: usize = 286;
|
||||
pub const WAVEFORM_SAMPLES: usize = 128;
|
||||
|
||||
const DATE_OFFSET: usize = 272;
|
||||
const TIME_OFFSET: usize = 283;
|
||||
|
||||
fn f32_at(d: &[u8], o: usize) -> f64 {
|
||||
f32::from_le_bytes([d[o], d[o + 1], d[o + 2], d[o + 3]]) as f64
|
||||
}
|
||||
|
||||
/// ASCII with U+FFFD for bytes above 0x7F and trailing NULs removed,
|
||||
/// like Python's `decode("ascii", errors="replace").rstrip("\0")`.
|
||||
fn ascii(d: &[u8]) -> String {
|
||||
let s: String = d
|
||||
.iter()
|
||||
.map(|&b| if b < 0x80 { b as char } else { '\u{FFFD}' })
|
||||
.collect();
|
||||
s.trim_end_matches('\0').to_string()
|
||||
}
|
||||
|
||||
/// One reading: the measurement block, plus the electrical block when it was read.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Reading {
|
||||
/// Device ID from the block header ("HPCS6500").
|
||||
pub device: String,
|
||||
/// Date and time the instrument stamped on the reading.
|
||||
pub test_date: String,
|
||||
pub test_time: String,
|
||||
/// The [`FIELDS`] in order; use [`Reading::get`] by name.
|
||||
pub values: [f64; FIELD_COUNT],
|
||||
/// 350 points, 380 to 1050 nm (spectral irradiance, µW/cm²/nm).
|
||||
pub spectrum: Vec<f64>,
|
||||
pub electrical: Option<Electrical>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Electrical {
|
||||
pub voltage_v: f64,
|
||||
pub current_a: f64,
|
||||
pub power_w: f64,
|
||||
pub freq_hz: f64,
|
||||
pub pf: f64,
|
||||
/// Present when harmonics analysis is on (H1 of the voltage is 100 %).
|
||||
pub harmonics: Option<Harmonics>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Harmonics {
|
||||
/// H1..H50 of the voltage, % of H1.
|
||||
pub voltage: Vec<f64>,
|
||||
pub uthd: f64,
|
||||
/// H1..H50 of the current, % of H1.
|
||||
pub current: Vec<f64>,
|
||||
pub athd: f64,
|
||||
/// One AC cycle, 128 samples each.
|
||||
pub v_waveform: Vec<i16>,
|
||||
pub i_waveform: Vec<i16>,
|
||||
}
|
||||
|
||||
impl Electrical {
|
||||
/// Parse the 1584-byte electrical block.
|
||||
pub fn parse(d: &[u8]) -> Option<Electrical> {
|
||||
if d.len() < 28 {
|
||||
return None;
|
||||
}
|
||||
let harmonics = (d.len() >= ATHD_OFFSET + 4
|
||||
&& (f32_at(d, HARMONICS_V_OFFSET) - 100.0).abs() < 1.0)
|
||||
.then(|| {
|
||||
let i16s = |o: usize| {
|
||||
(0..WAVEFORM_SAMPLES)
|
||||
.map(|i| i16::from_le_bytes([d[o + 2 * i], d[o + 2 * i + 1]]))
|
||||
.collect()
|
||||
};
|
||||
Harmonics {
|
||||
voltage: (0..HARMONICS_COUNT)
|
||||
.map(|i| f32_at(d, HARMONICS_V_OFFSET + 4 * i))
|
||||
.collect(),
|
||||
uthd: f32_at(d, UTHD_OFFSET),
|
||||
current: (0..HARMONICS_COUNT)
|
||||
.map(|i| f32_at(d, HARMONICS_I_OFFSET + 4 * i))
|
||||
.collect(),
|
||||
athd: f32_at(d, ATHD_OFFSET),
|
||||
v_waveform: i16s(WAVEFORM_V_OFFSET),
|
||||
i_waveform: i16s(WAVEFORM_I_OFFSET),
|
||||
}
|
||||
});
|
||||
Some(Electrical {
|
||||
voltage_v: f32_at(d, 8),
|
||||
current_a: f32_at(d, 12),
|
||||
power_w: f32_at(d, 16),
|
||||
freq_hz: f32_at(d, 20),
|
||||
pf: f32_at(d, 24),
|
||||
harmonics,
|
||||
})
|
||||
}
|
||||
|
||||
fn values(&self) -> [f64; 5] {
|
||||
[
|
||||
self.voltage_v,
|
||||
self.current_a,
|
||||
self.power_w,
|
||||
self.freq_hz,
|
||||
self.pf,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
impl Reading {
|
||||
/// Parse a 3904-byte measurement block and optionally its electrical block.
|
||||
pub fn parse(meas: &[u8], elec: Option<&[u8]>) -> Option<Reading> {
|
||||
if meas.len() < MEASUREMENT_LEN {
|
||||
return None;
|
||||
}
|
||||
let mut values = [0.0; FIELD_COUNT];
|
||||
for (v, (_, off)) in values.iter_mut().zip(FIELDS.iter()) {
|
||||
*v = f32_at(meas, *off);
|
||||
}
|
||||
Some(Reading {
|
||||
device: ascii(&meas[..10]),
|
||||
test_date: ascii(&meas[DATE_OFFSET..DATE_OFFSET + 10]),
|
||||
test_time: ascii(&meas[TIME_OFFSET..TIME_OFFSET + 8]),
|
||||
values,
|
||||
spectrum: (0..SPECTRUM_COUNT)
|
||||
.map(|i| f32_at(meas, SPECTRUM_OFFSET + 4 * i))
|
||||
.collect(),
|
||||
electrical: elec.and_then(Electrical::parse),
|
||||
})
|
||||
}
|
||||
|
||||
/// A value by its CSV / bundle name: the [`FIELDS`], the
|
||||
/// [`ELECTRICAL_FIELDS`], `UThd` and `AThd`. `None` when absent.
|
||||
pub fn get(&self, name: &str) -> Option<f64> {
|
||||
if let Some(i) = FIELDS.iter().position(|(n, _)| *n == name) {
|
||||
return Some(self.values[i]);
|
||||
}
|
||||
let e = self.electrical.as_ref()?;
|
||||
if let Some(i) = ELECTRICAL_FIELDS.iter().position(|(n, _)| *n == name) {
|
||||
return Some(e.values()[i]);
|
||||
}
|
||||
let h = e.harmonics.as_ref()?;
|
||||
match name {
|
||||
"UThd" => Some(h.uthd),
|
||||
"AThd" => Some(h.athd),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a scalar by name (see [`Reading::get`]); false if it does not exist here.
|
||||
pub fn set(&mut self, name: &str, value: f64) -> bool {
|
||||
if let Some(i) = FIELDS.iter().position(|(n, _)| *n == name) {
|
||||
self.values[i] = value;
|
||||
return true;
|
||||
}
|
||||
let Some(e) = self.electrical.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
match name {
|
||||
"Voltage_V" => e.voltage_v = value,
|
||||
"Current_A" => e.current_a = value,
|
||||
"Power_W" => e.power_w = value,
|
||||
"Freq_Hz" => e.freq_hz = value,
|
||||
"PF" => e.pf = value,
|
||||
"UThd" | "AThd" => {
|
||||
let Some(h) = e.harmonics.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
if name == "UThd" {
|
||||
h.uthd = value
|
||||
} else {
|
||||
h.athd = value
|
||||
}
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// All scalars present in this reading, in the Python driver's key order.
|
||||
pub fn scalars(&self) -> Vec<(&'static str, f64)> {
|
||||
let mut v: Vec<(&'static str, f64)> =
|
||||
FIELDS.iter().map(|(n, _)| *n).zip(self.values).collect();
|
||||
if let Some(e) = &self.electrical {
|
||||
v.extend(ELECTRICAL_FIELDS.iter().map(|(n, _)| *n).zip(e.values()));
|
||||
if let Some(h) = &e.harmonics {
|
||||
v.push(("UThd", h.uthd));
|
||||
v.push(("AThd", h.athd));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Wavelength of each spectrum point (nm).
|
||||
pub fn spectrum_nm(&self) -> Vec<f64> {
|
||||
(0..self.spectrum.len())
|
||||
.map(|i| SPECTRUM_START_NM + i as f64 * SPECTRUM_STEP)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn field(&self, name: &str) -> f64 {
|
||||
self.get(name).unwrap_or(0.0)
|
||||
}
|
||||
/// Luminous flux (lm).
|
||||
pub fn flux_lm(&self) -> f64 {
|
||||
self.field("Phi_lm")
|
||||
}
|
||||
/// Luminous efficacy (lm/W).
|
||||
pub fn efficacy_lm_w(&self) -> f64 {
|
||||
self.field("eta_lm_W")
|
||||
}
|
||||
/// Correlated colour temperature (K).
|
||||
pub fn cct_k(&self) -> f64 {
|
||||
self.field("CCT_K")
|
||||
}
|
||||
pub fn duv(&self) -> f64 {
|
||||
self.field("Duv")
|
||||
}
|
||||
/// CRI Ra.
|
||||
pub fn ra(&self) -> f64 {
|
||||
self.field("Ra")
|
||||
}
|
||||
/// Special CRI R1..R15.
|
||||
pub fn r(&self, n: usize) -> f64 {
|
||||
assert!((1..=15).contains(&n), "R1..R15");
|
||||
self.values[11 + n]
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON with the Python driver's keys and order: `device`, the fields,
|
||||
/// `test_date`, `test_time`, `spectrum`, `spectrum_nm`, then the electrical
|
||||
/// values and harmonics when present.
|
||||
impl Serialize for Reading {
|
||||
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
let mut m = s.serialize_map(None)?;
|
||||
m.serialize_entry("device", &self.device)?;
|
||||
for ((n, _), v) in FIELDS.iter().zip(self.values) {
|
||||
m.serialize_entry(n, &v)?;
|
||||
}
|
||||
m.serialize_entry("test_date", &self.test_date)?;
|
||||
m.serialize_entry("test_time", &self.test_time)?;
|
||||
m.serialize_entry("spectrum", &self.spectrum)?;
|
||||
m.serialize_entry("spectrum_nm", &self.spectrum_nm())?;
|
||||
if let Some(e) = &self.electrical {
|
||||
for ((n, _), v) in ELECTRICAL_FIELDS.iter().zip(e.values()) {
|
||||
m.serialize_entry(n, &v)?;
|
||||
}
|
||||
if let Some(h) = &e.harmonics {
|
||||
m.serialize_entry("V_harmonics", &h.voltage)?;
|
||||
m.serialize_entry("UThd", &h.uthd)?;
|
||||
m.serialize_entry("I_harmonics", &h.current)?;
|
||||
m.serialize_entry("AThd", &h.athd)?;
|
||||
m.serialize_entry("V_waveform", &h.v_waveform)?;
|
||||
m.serialize_entry("I_waveform", &h.i_waveform)?;
|
||||
}
|
||||
}
|
||||
m.end()
|
||||
}
|
||||
}
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
//! A simulated instrument: answers every command of the protocol like the
|
||||
//! HPCS 6500 does, so tools can be developed and tested without hardware.
|
||||
//!
|
||||
//! Readings come from a USB capture ([`Simulator::from_pcap`]) or from the
|
||||
//! built-in sample ([`Simulator::sample`], a real reading of an LED lamp).
|
||||
//! Every frame the host writes is kept in [`Simulator::log`].
|
||||
|
||||
use crate::device::Transport;
|
||||
use crate::error::Result;
|
||||
use crate::pcap::{self, Dir};
|
||||
use crate::protocol::{self, ELECTRICAL_HEADER, MEASUREMENT_HEADER, Mode, PsuSettings, SYNC, cmd};
|
||||
use std::collections::VecDeque;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
const SAMPLE_MEAS: &[u8] = include_bytes!("../tests/fixtures/r01_meas.bin");
|
||||
const SAMPLE_ELEC: &[u8] = include_bytes!("../tests/fixtures/r01_elec.bin");
|
||||
|
||||
/// The simulated instrument (a [`Transport`] for [`crate::Hpcs6500`]).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Simulator {
|
||||
readings: Vec<(Vec<u8>, Vec<u8>)>,
|
||||
identify: Vec<u8>,
|
||||
/// Supply settings as set by the host.
|
||||
pub psu: PsuSettings,
|
||||
/// Supply output on?
|
||||
pub output_on: bool,
|
||||
/// Last integration time set (µs), 0 = auto.
|
||||
pub integration_us: u32,
|
||||
/// Polls after a trigger until data is ready.
|
||||
pub polls_to_ready: u32,
|
||||
/// Readings served so far.
|
||||
pub served: usize,
|
||||
/// Every frame written by the host, in order.
|
||||
pub log: Vec<Vec<u8>>,
|
||||
/// Commands the instrument ignores (no answer at all), to test timeouts.
|
||||
pub mute: Vec<u8>,
|
||||
countdown: Option<u32>,
|
||||
ready: bool,
|
||||
continuous: bool,
|
||||
out: VecDeque<u8>,
|
||||
}
|
||||
|
||||
impl Simulator {
|
||||
/// Serve these (measurement, electrical) block pairs in a loop.
|
||||
pub fn new(readings: Vec<(Vec<u8>, Vec<u8>)>) -> Self {
|
||||
assert!(!readings.is_empty(), "at least one reading");
|
||||
let mut identify = vec![SYNC, cmd::IDENTIFY];
|
||||
identify.extend_from_slice(b"HPCS6500\0\0");
|
||||
identify.extend_from_slice(&[0; 4]);
|
||||
Simulator {
|
||||
readings,
|
||||
identify,
|
||||
psu: PsuSettings {
|
||||
ac_voltage: 230.0,
|
||||
ac_frequency: 50.0,
|
||||
dc_voltage: 24.0,
|
||||
dc_current: 5.0,
|
||||
mode: Mode::Ac,
|
||||
},
|
||||
output_on: false,
|
||||
integration_us: 0,
|
||||
polls_to_ready: 2,
|
||||
served: 0,
|
||||
log: vec![],
|
||||
mute: vec![],
|
||||
countdown: None,
|
||||
ready: false,
|
||||
continuous: false,
|
||||
out: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The built-in sample reading (an LED lamp at 230 V / 50 Hz).
|
||||
pub fn sample() -> Self {
|
||||
Simulator::new(vec![(SAMPLE_MEAS.to_vec(), SAMPLE_ELEC.to_vec())])
|
||||
}
|
||||
|
||||
/// Replay the readings of a USB capture; the identify and supply answers
|
||||
/// are taken from the capture too when it has them.
|
||||
pub fn from_pcap(path: impl AsRef<Path>) -> Result<Self> {
|
||||
let msgs = pcap::read_messages(path.as_ref())?;
|
||||
let blocks = pcap::blocks(&msgs);
|
||||
if blocks.is_empty() {
|
||||
return Err(crate::Error::Pcap(format!(
|
||||
"{}: no measurement blocks",
|
||||
path.as_ref().display()
|
||||
)));
|
||||
}
|
||||
let mut s = Simulator::new(
|
||||
blocks
|
||||
.into_iter()
|
||||
.map(|b| {
|
||||
(
|
||||
b.measurement,
|
||||
b.electrical
|
||||
.unwrap_or_else(|| vec![0; protocol::ELECTRICAL_LEN]),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
let rx = || msgs.iter().filter(|m| m.dir == Dir::Rx);
|
||||
if let Some(m) = rx().find(|m| m.data.len() == 16 && m.data[..2] == [SYNC, cmd::IDENTIFY]) {
|
||||
s.identify = m.data.clone();
|
||||
}
|
||||
if let Some(p) = rx()
|
||||
.find(|m| {
|
||||
m.data.len() == PsuSettings::RESPONSE_LEN && m.data[..2] == [SYNC, cmd::READ_PSU]
|
||||
})
|
||||
.and_then(|m| PsuSettings::parse(&m.data[2..]))
|
||||
{
|
||||
s.psu = p;
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Number of distinct readings it serves.
|
||||
pub fn len(&self) -> usize {
|
||||
self.readings.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// The frames of the log with this command byte.
|
||||
pub fn sent(&self, command: u8) -> Vec<&Vec<u8>> {
|
||||
self.log
|
||||
.iter()
|
||||
.filter(|f| f.len() >= 2 && f[1] == command)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn reply(&mut self, b: &[u8]) {
|
||||
self.out.extend(b);
|
||||
}
|
||||
fn ack(&mut self, c: u8) {
|
||||
self.reply(&[SYNC, c]);
|
||||
}
|
||||
fn trigger(&mut self, continuous: bool) {
|
||||
self.continuous = continuous;
|
||||
self.ready = false;
|
||||
self.countdown = Some(self.polls_to_ready);
|
||||
}
|
||||
fn current(&self) -> &(Vec<u8>, Vec<u8>) {
|
||||
&self.readings[self.served.saturating_sub(1) % self.readings.len()]
|
||||
}
|
||||
fn f32_arg(f: &[u8]) -> Option<(u8, f32)> {
|
||||
(f.len() >= 7).then(|| (f[2], f32::from_le_bytes([f[3], f[4], f[5], f[6]])))
|
||||
}
|
||||
|
||||
fn handle(&mut self, f: &[u8]) {
|
||||
if f.len() < 2 || f[0] != SYNC || self.mute.contains(&f[1]) {
|
||||
return;
|
||||
}
|
||||
let c = f[1];
|
||||
let arg = f.get(2).copied();
|
||||
match c {
|
||||
cmd::IDENTIFY => {
|
||||
let r = self.identify.clone();
|
||||
self.reply(&r)
|
||||
}
|
||||
cmd::INTEGRATION_TIME if f.len() >= 6 => {
|
||||
self.integration_us = u32::from_le_bytes([f[2], f[3], f[4], f[5]]);
|
||||
self.ack(c)
|
||||
}
|
||||
cmd::POLL_STATE => {
|
||||
if let Some(n) = self.countdown {
|
||||
if n == 0 {
|
||||
self.countdown = None;
|
||||
self.ready = true;
|
||||
self.served += 1;
|
||||
} else {
|
||||
self.countdown = Some(n - 1);
|
||||
}
|
||||
}
|
||||
let r = self.ready as u8;
|
||||
let state = if self.continuous || self.countdown.is_some() {
|
||||
protocol::STATE_MEASURING
|
||||
} else {
|
||||
protocol::STATE_IDLE
|
||||
};
|
||||
self.reply(&[SYNC, c, r, r, 0, state, 0, 0, 1])
|
||||
}
|
||||
cmd::STATUS => self.reply(&[SYNC, c, 0, 0, 0, 0, 0]),
|
||||
cmd::START => {
|
||||
match arg {
|
||||
Some(protocol::START_CONTINUOUS) => self.trigger(true),
|
||||
// stop ends a continuous run; on an idle instrument it takes one reading
|
||||
Some(protocol::STOP_OR_SINGLE) if self.continuous => {
|
||||
self.continuous = false;
|
||||
self.countdown = None;
|
||||
}
|
||||
Some(protocol::STOP_OR_SINGLE) => self.trigger(false),
|
||||
_ => {}
|
||||
}
|
||||
self.ack(c)
|
||||
}
|
||||
cmd::READ_MEASUREMENT => {
|
||||
let mut r = MEASUREMENT_HEADER.to_vec();
|
||||
r.extend_from_slice(&self.current().0);
|
||||
self.reply(&r);
|
||||
self.ready = false;
|
||||
if self.continuous {
|
||||
self.countdown = Some(self.polls_to_ready);
|
||||
}
|
||||
}
|
||||
cmd::READ_ELECTRICAL => {
|
||||
let mut r = ELECTRICAL_HEADER.to_vec();
|
||||
r.extend_from_slice(&self.current().1);
|
||||
self.reply(&r)
|
||||
}
|
||||
cmd::RESET => {
|
||||
self.continuous = false;
|
||||
self.countdown = None;
|
||||
self.ready = false;
|
||||
self.ack(c)
|
||||
}
|
||||
cmd::TEST_CONFIG => self.ack(c),
|
||||
cmd::PSU_OUTPUT => {
|
||||
match arg {
|
||||
Some(0x00) => self.output_on = true,
|
||||
Some(0x01) => self.output_on = false,
|
||||
_ => {}
|
||||
}
|
||||
self.ack(c)
|
||||
}
|
||||
cmd::SET_DC => {
|
||||
if let Some((sub, v)) = Self::f32_arg(f) {
|
||||
if sub == 0 {
|
||||
self.psu.dc_current = v as f64
|
||||
} else {
|
||||
self.psu.dc_voltage = v as f64
|
||||
}
|
||||
}
|
||||
self.ack(c)
|
||||
}
|
||||
cmd::SET_AC => {
|
||||
if let Some((sub, v)) = Self::f32_arg(f) {
|
||||
if sub == 0 {
|
||||
self.psu.ac_voltage = v as f64
|
||||
} else {
|
||||
self.psu.ac_frequency = v as f64
|
||||
}
|
||||
}
|
||||
self.ack(c)
|
||||
}
|
||||
cmd::READ_PSU => {
|
||||
let r = self.psu.to_response();
|
||||
self.reply(&r)
|
||||
}
|
||||
cmd::SET_MODE => {
|
||||
self.psu.mode = if arg == Some(1) { Mode::Dc } else { Mode::Ac };
|
||||
self.ack(c)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Transport for Simulator {
|
||||
fn write_all(&mut self, data: &[u8]) -> io::Result<()> {
|
||||
self.log.push(data.to_vec());
|
||||
self.handle(data);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_some(&mut self, buf: &mut [u8], timeout: Duration) -> io::Result<usize> {
|
||||
if self.out.is_empty() {
|
||||
std::thread::sleep(timeout.min(Duration::from_millis(5)));
|
||||
return Ok(0);
|
||||
}
|
||||
let n = buf.len().min(self.out.len());
|
||||
for (b, x) in buf.iter_mut().zip(self.out.drain(..n)) {
|
||||
*b = x;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
fn clear_input(&mut self) -> io::Result<()> {
|
||||
self.out.clear();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
+189
@@ -0,0 +1,189 @@
|
||||
//! Golden tests against the output of the former Python driver
|
||||
//! (`hpcs6500.py`), generated from real USB captures.
|
||||
|
||||
use hpcs6500::format::{csv_header, csv_row, format_quick, format_reading, pcap_report};
|
||||
use hpcs6500::{Reading, pcap, pyfmt};
|
||||
use serde_json::Value;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn dir(sub: &str) -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests")
|
||||
.join(sub)
|
||||
}
|
||||
|
||||
fn fixture(name: &str) -> Reading {
|
||||
let meas = std::fs::read(dir("fixtures").join(format!("{name}_meas.bin"))).unwrap();
|
||||
let elec = std::fs::read(dir("fixtures").join(format!("{name}_elec.bin"))).ok();
|
||||
Reading::parse(&meas, elec.as_deref()).unwrap()
|
||||
}
|
||||
|
||||
fn golden() -> Vec<Value> {
|
||||
serde_json::from_str(&std::fs::read_to_string(dir("golden").join("readings.json")).unwrap())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn text(name: &str) -> String {
|
||||
std::fs::read_to_string(dir("golden").join(name))
|
||||
.unwrap()
|
||||
.replace("\r\n", "\n")
|
||||
}
|
||||
|
||||
/// Python values in the golden file: floats as {"f": repr}, lists, ints, strings.
|
||||
fn same(rust: &Value, py: &Value, path: &str) {
|
||||
match py {
|
||||
Value::Object(o) if o.contains_key("f") => {
|
||||
let want = o["f"].as_str().unwrap();
|
||||
let got = pyfmt::repr(
|
||||
rust.as_f64()
|
||||
.unwrap_or_else(|| panic!("{path}: not a number: {rust}")),
|
||||
);
|
||||
assert_eq!(got, want, "{path}");
|
||||
}
|
||||
Value::Array(a) => {
|
||||
let r = rust
|
||||
.as_array()
|
||||
.unwrap_or_else(|| panic!("{path}: not a list"));
|
||||
assert_eq!(r.len(), a.len(), "{path}: length");
|
||||
for (i, (x, y)) in r.iter().zip(a).enumerate() {
|
||||
same(x, y, &format!("{path}[{i}]"));
|
||||
}
|
||||
}
|
||||
other => assert_eq!(rust, other, "{path}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsed_blocks_match_python_exactly() {
|
||||
for g in golden() {
|
||||
let name = g["name"].as_str().unwrap();
|
||||
let r = fixture(name);
|
||||
let v = serde_json::to_value(&r).unwrap();
|
||||
let keys: Vec<&str> = v.as_object().unwrap().keys().map(|k| k.as_str()).collect();
|
||||
let want: Vec<&str> = g["keys"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|k| k.as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(keys, want, "{name}: key order");
|
||||
for k in want {
|
||||
same(&v[k], &g["values"][k], &format!("{name}.{k}"));
|
||||
}
|
||||
assert_eq!(
|
||||
r.electrical.as_ref().unwrap().harmonics.is_some(),
|
||||
g["harmonics"].as_bool().unwrap(),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_text_matches_python() {
|
||||
for g in golden() {
|
||||
let name = g["name"].as_str().unwrap();
|
||||
let r = fixture(name);
|
||||
assert_eq!(
|
||||
format_reading(&r, false),
|
||||
text(&format!("format_{name}.txt")),
|
||||
"{name}"
|
||||
);
|
||||
assert_eq!(
|
||||
format_reading(&r, true),
|
||||
text(&format!("format_{name}_spectrum.txt")),
|
||||
"{name} spectrum"
|
||||
);
|
||||
let n: usize = name[1..].parse().unwrap();
|
||||
assert_eq!(
|
||||
format!("{}\n{}", format_quick(&r, Some(n)), format_quick(&r, None)),
|
||||
text(&format!("quick_{name}.txt"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_matches_python() {
|
||||
for g in golden() {
|
||||
let name = g["name"].as_str().unwrap();
|
||||
let want = std::fs::read_to_string(dir("golden").join(format!("csv_{name}.txt"))).unwrap();
|
||||
assert_eq!(csv_header() + &csv_row("T", &fixture(name)), want, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_formatting_matches_python() {
|
||||
let cases: Vec<Value> =
|
||||
serde_json::from_str(&std::fs::read_to_string(dir("golden").join("pyfmt.json")).unwrap())
|
||||
.unwrap();
|
||||
assert!(cases.len() > 1000);
|
||||
for c in &cases {
|
||||
let x = f64::from_bits(c["bits"].as_u64().unwrap());
|
||||
assert_eq!(pyfmt::repr(x), c["repr"].as_str().unwrap(), "repr {x:e}");
|
||||
assert_eq!(pyfmt::g(x, 6), c["g6"].as_str().unwrap(), "g6 {x:e}");
|
||||
for (k, p) in [("f2", 2), ("f1", 1), ("f0", 0)] {
|
||||
if let Some(w) = c[k].as_str() {
|
||||
assert_eq!(pyfmt::fixed(x, p), w, "{k} {x:e}");
|
||||
}
|
||||
}
|
||||
if let Some(w) = c["round1"].as_str() {
|
||||
assert_eq!(pyfmt::repr(pyfmt::round(x, 1)), w, "round1 {x:e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pcap_round_trip_and_report() {
|
||||
// a capture with two readings, written in memory
|
||||
let (a, b) = (fixture("r01"), fixture("r02"));
|
||||
let block =
|
||||
|n: &str, k: &str| std::fs::read(dir("fixtures").join(format!("{n}_{k}.bin"))).unwrap();
|
||||
let file = pcap::write(&[
|
||||
(pcap::Dir::Tx, vec![0x8C, 0x13]),
|
||||
(pcap::Dir::Rx, vec![0x8C, 0x13, 0x0F, 0x40]),
|
||||
(pcap::Dir::Rx, block("r01", "meas")),
|
||||
(pcap::Dir::Rx, block("r01", "elec")),
|
||||
(pcap::Dir::Rx, block("r02", "meas")),
|
||||
(pcap::Dir::Rx, block("r02", "elec")),
|
||||
]);
|
||||
let msgs = pcap::messages(&file).unwrap();
|
||||
assert_eq!(msgs.len(), 6);
|
||||
assert_eq!(msgs[0].dir, pcap::Dir::Tx);
|
||||
assert_eq!(pcap::readings(&msgs), vec![a, b]);
|
||||
let report = pcap_report(&msgs, true, false);
|
||||
assert_eq!(report.lines().count(), 2);
|
||||
assert!(pcap::messages(&[]).is_err());
|
||||
assert!(pcap::messages(&[0u8; 30]).is_err());
|
||||
}
|
||||
|
||||
/// The `--parse` reports of real captures. The captures are not in git
|
||||
/// (`captures/` is ignored); the test runs where they are.
|
||||
#[test]
|
||||
fn parse_reports_of_real_captures() {
|
||||
let caps = Path::new(env!("CARGO_MANIFEST_DIR")).join("captures");
|
||||
let mut checked = 0;
|
||||
for cap in [
|
||||
"usb_capture_20260204_123655_hub1",
|
||||
"usb_capture_20260204_134301_hub1",
|
||||
] {
|
||||
let path = caps.join(format!("{cap}.pcap"));
|
||||
if !path.exists() {
|
||||
eprintln!("skipped: {} not here", path.display());
|
||||
continue;
|
||||
}
|
||||
let msgs = pcap::read_messages(&path).unwrap();
|
||||
assert_eq!(
|
||||
pcap_report(&msgs, false, false),
|
||||
text(&format!("parse_{cap}.txt"))
|
||||
);
|
||||
assert_eq!(
|
||||
pcap_report(&msgs, true, false),
|
||||
text(&format!("parse_{cap}_quick.txt"))
|
||||
);
|
||||
assert_eq!(
|
||||
pcap_report(&msgs, false, true),
|
||||
text(&format!("parse_{cap}_harmonics.txt"))
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
eprintln!("{checked} capture(s) checked");
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
timestamp,Phi_lm,eta_lm_W,CCT_K,Duv,x,y,u,v,u_prime,v_prime,SDCM,Ra,R1,R2,R3,R4,R5,R6,R7,R8,R9,R10,R11,R12,R13,R14,R15,Phi_e_mW,Phi_euv_mW,Phi_eb_mW,Phi_ey_mW,Phi_er_mW,Phi_efr_mW,Phi_eir_mW,Phi_e_total,CIE_X,CIE_Y,CIE_Z,TLCI,PeakSignal,DarkSignal,Compensate,Voltage_V,Current_A,Power_W,Freq_Hz,PF
|
||||
T,483.7375183105469,57.40340042114258,5635.47412109375,0.005606670398265123,0.32931944727897644,0.34943076968193054,0.2015872299671173,0.3208470344543457,0.2015872299671173,0.48127055168151855,4.836654186248779,82.90641784667969,81.94468688964844,93.72439575195312,93.53913116455078,76.66526794433594,81.33158874511719,88.99320983886719,83.17845916748047,63.87458801269531,4.212823390960693,83.55355072021484,76.21843719482422,59.73139953613281,86.1976318359375,96.91849517822266,75.6298828125,1501.3740234375,0.0,471.6053466796875,686.2144775390625,333.9613952636719,11.230633735656738,0.0,1503.01220703125,667.4908447265625,708.2540283203125,651.1345825195312,67.85055541992188,53279.0,2154.0,2834.0,230.29359436035156,0.06537289172410965,8.406862258911133,50.02156448364258,0.5584115386009216
|
||||
@@ -0,0 +1,2 @@
|
||||
timestamp,Phi_lm,eta_lm_W,CCT_K,Duv,x,y,u,v,u_prime,v_prime,SDCM,Ra,R1,R2,R3,R4,R5,R6,R7,R8,R9,R10,R11,R12,R13,R14,R15,Phi_e_mW,Phi_euv_mW,Phi_eb_mW,Phi_ey_mW,Phi_er_mW,Phi_efr_mW,Phi_eir_mW,Phi_e_total,CIE_X,CIE_Y,CIE_Z,TLCI,PeakSignal,DarkSignal,Compensate,Voltage_V,Current_A,Power_W,Freq_Hz,PF
|
||||
T,479.5904235839844,57.03839111328125,5661.3740234375,0.005495334509760141,0.3286939859390259,0.34864285588264465,0.20145727694034576,0.32052600383758545,0.20145727694034576,0.4807890057563782,4.661009311676025,83.03077697753906,82.11673736572266,93.9566421508789,93.32406616210938,76.64042663574219,81.5961685180664,89.55091094970703,83.03118896484375,64.03005981445312,4.829061031341553,84.0609130859375,76.2067642211914,60.158180236816406,86.46263885498047,96.80621337890625,75.96227264404297,1492.0255126953125,0.0,470.2662658691406,679.6720581054688,330.6041564941406,11.205649375915527,0.0,1491.7479248046875,662.0042114257812,702.18212890625,649.8577880859375,67.88436889648438,53165.0,2310.0,2834.0,230.289794921875,0.06533221900463104,8.404895782470703,50.02182388305664,0.5585951805114746
|
||||
@@ -0,0 +1,2 @@
|
||||
timestamp,Phi_lm,eta_lm_W,CCT_K,Duv,x,y,u,v,u_prime,v_prime,SDCM,Ra,R1,R2,R3,R4,R5,R6,R7,R8,R9,R10,R11,R12,R13,R14,R15,Phi_e_mW,Phi_euv_mW,Phi_eb_mW,Phi_ey_mW,Phi_er_mW,Phi_efr_mW,Phi_eir_mW,Phi_e_total,CIE_X,CIE_Y,CIE_Z,TLCI,PeakSignal,DarkSignal,Compensate,Voltage_V,Current_A,Power_W,Freq_Hz,PF
|
||||
T,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0
|
||||
@@ -0,0 +1,2 @@
|
||||
timestamp,Phi_lm,eta_lm_W,CCT_K,Duv,x,y,u,v,u_prime,v_prime,SDCM,Ra,R1,R2,R3,R4,R5,R6,R7,R8,R9,R10,R11,R12,R13,R14,R15,Phi_e_mW,Phi_euv_mW,Phi_eb_mW,Phi_ey_mW,Phi_er_mW,Phi_efr_mW,Phi_eir_mW,Phi_e_total,CIE_X,CIE_Y,CIE_Z,TLCI,PeakSignal,DarkSignal,Compensate,Voltage_V,Current_A,Power_W,Freq_Hz,PF
|
||||
T,479.8302917480469,57.09168243408203,5655.5185546875,0.005520321428775787,0.32882028818130493,0.3488074243068695,0.20148152112960815,0.3205926716327667,0.20148152112960815,0.4808890223503113,4.696867942810059,83.00994873046875,82.22040557861328,94.05892181396484,93.2469253540039,76.50690460205078,81.42030334472656,89.34086608886719,83.16636657714844,64.1189193725586,4.818240642547607,84.18575286865234,76.09269714355469,60.0699348449707,86.59054565429688,96.74826049804688,76.01618194580078,1491.9422607421875,0.0,469.8638610839844,679.650634765625,330.7580261230469,11.141565322875977,0.0,1491.4139404296875,662.2772216796875,702.5333251953125,649.290283203125,67.87500762939453,53051.0,2228.0,2834.0,230.30111694335938,0.06532319635152817,8.404557228088379,50.02182388305664,0.558664858341217
|
||||
@@ -0,0 +1,2 @@
|
||||
timestamp,Phi_lm,eta_lm_W,CCT_K,Duv,x,y,u,v,u_prime,v_prime,SDCM,Ra,R1,R2,R3,R4,R5,R6,R7,R8,R9,R10,R11,R12,R13,R14,R15,Phi_e_mW,Phi_euv_mW,Phi_eb_mW,Phi_ey_mW,Phi_er_mW,Phi_efr_mW,Phi_eir_mW,Phi_e_total,CIE_X,CIE_Y,CIE_Z,TLCI,PeakSignal,DarkSignal,Compensate,Voltage_V,Current_A,Power_W,Freq_Hz,PF
|
||||
T,479.61688232421875,57.09785461425781,5655.04931640625,0.005514893215149641,0.32883065938949585,0.34880587458610535,0.20148910582065582,0.3205931782722473,0.20148910582065582,0.48088976740837097,4.689785480499268,83.1019287109375,82.3944320678711,94.21000671386719,93.1120834350586,76.46770477294922,81.47521209716797,89.57075500488281,83.24170684814453,64.343505859375,5.2721099853515625,84.44823455810547,76.06077575683594,60.25636291503906,86.82209777832031,96.66696166992188,76.29528045654297,1491.543701171875,0.0,469.8504333496094,679.9556274414062,331.67364501953125,11.56343936920166,0.0,1493.0426025390625,662.0065307617188,702.2208862304688,648.9866943359375,67.98677825927734,53066.0,2236.0,2834.0,230.29080200195312,0.06529296934604645,8.399910926818848,50.02178955078125,0.5586221218109131
|
||||
@@ -0,0 +1,2 @@
|
||||
timestamp,Phi_lm,eta_lm_W,CCT_K,Duv,x,y,u,v,u_prime,v_prime,SDCM,Ra,R1,R2,R3,R4,R5,R6,R7,R8,R9,R10,R11,R12,R13,R14,R15,Phi_e_mW,Phi_euv_mW,Phi_eb_mW,Phi_ey_mW,Phi_er_mW,Phi_efr_mW,Phi_eir_mW,Phi_e_total,CIE_X,CIE_Y,CIE_Z,TLCI,PeakSignal,DarkSignal,Compensate,Voltage_V,Current_A,Power_W,Freq_Hz,PF
|
||||
T,479.6141357421875,57.04836654663086,5654.22216796875,0.005528819747269154,0.32884812355041504,0.34884971380233765,0.20148463547229767,0.32060936093330383,0.20148463547229767,0.48091405630111694,4.7090373039245605,82.99382781982422,82.20687103271484,94.0466079711914,93.27873229980469,76.49690246582031,81.38878631591797,89.25926208496094,83.16966247558594,64.1037368774414,4.81308126449585,84.15076446533203,76.0714340209961,60.03883743286133,86.56330108642578,96.76630401611328,75.97416687011719,1491.4090576171875,0.0,470.1175231933594,679.6602783203125,331.024658203125,11.806631088256836,0.0,1492.6082763671875,661.95458984375,702.2168579101562,648.7780151367188,67.90325927734375,53066.0,2246.0,2834.0,230.29782104492188,0.06530531495809555,8.407149314880371,50.02182388305664,0.5589380860328674
|
||||
@@ -0,0 +1,2 @@
|
||||
timestamp,Phi_lm,eta_lm_W,CCT_K,Duv,x,y,u,v,u_prime,v_prime,SDCM,Ra,R1,R2,R3,R4,R5,R6,R7,R8,R9,R10,R11,R12,R13,R14,R15,Phi_e_mW,Phi_euv_mW,Phi_eb_mW,Phi_ey_mW,Phi_er_mW,Phi_efr_mW,Phi_eir_mW,Phi_e_total,CIE_X,CIE_Y,CIE_Z,TLCI,PeakSignal,DarkSignal,Compensate,Voltage_V,Current_A,Power_W,Freq_Hz,PF
|
||||
T,182.61920166015625,0.0,4936.84375,0.002135422546416521,0.3473931849002838,0.35774698853492737,0.21059946715831757,0.32531434297561646,0.21059946715831757,0.4879715144634247,1.5234547853469849,83.53800201416016,81.74681854248047,89.89591979980469,94.66126251220703,81.88838958740234,81.9393081665039,84.4315185546875,86.51227569580078,67.2285385131836,12.525043487548828,75.23241424560547,80.69010925292969,53.97742462158203,84.02236938476562,97.37865447998047,76.29852294921875,558.6363525390625,0.0,152.54922485351562,257.9691162109375,144.27931213378906,5.738129138946533,0.0,560.5353393554688,259.6396484375,267.3780212402344,220.3765106201172,71.05938720703125,55124.0,2518.0,2834.0,0.0,0.0,0.0,0.0,0.0
|
||||
@@ -0,0 +1,41 @@
|
||||
Device: HPCS6500
|
||||
Date: 2026-02-04 Time: 15:38:14
|
||||
|
||||
--- Photometric ---
|
||||
Phi (lm): 483.74
|
||||
eta (lm/W): 57.40
|
||||
CCT (K): 5635
|
||||
Duv: 0.00561
|
||||
|
||||
--- Chromaticity ---
|
||||
x: 0.3293 y: 0.3494
|
||||
u: 0.2016 v: 0.3208
|
||||
u': 0.2016 v': 0.4813
|
||||
|
||||
--- CRI ---
|
||||
Ra: 82.9 SDCM: 4.84
|
||||
R1=82, R2=94, R3=94, R4=77, R5=81, R6=89, R7=83, R8=64
|
||||
R9=4, R10=84, R11=76, R12=60, R13=86, R14=97, R15=76
|
||||
|
||||
--- CIE 1931 ---
|
||||
X: 667.491 Y: 708.254 Z: 651.135
|
||||
TLCI-2012: 68
|
||||
|
||||
--- Radiometric ---
|
||||
Phi_e (mW): 1501.374
|
||||
Phi_eb (mW): 471.605
|
||||
Phi_ey (mW): 686.214
|
||||
Phi_er (mW): 333.961
|
||||
Phi_efr (mW): 11.231
|
||||
|
||||
--- Electrical ---
|
||||
Voltage (V): 230.29
|
||||
Current (A): 0.0654
|
||||
Power (W): 8.407
|
||||
Frequency (Hz): 50.02
|
||||
Power Factor: 0.5584
|
||||
|
||||
--- Sensor ---
|
||||
Peak Signal: 53279
|
||||
Dark Signal: 2154
|
||||
Compensate: 2834
|
||||
@@ -0,0 +1,113 @@
|
||||
Device: HPCS6500
|
||||
Date: 2026-02-04 Time: 15:38:14
|
||||
|
||||
--- Photometric ---
|
||||
Phi (lm): 483.74
|
||||
eta (lm/W): 57.40
|
||||
CCT (K): 5635
|
||||
Duv: 0.00561
|
||||
|
||||
--- Chromaticity ---
|
||||
x: 0.3293 y: 0.3494
|
||||
u: 0.2016 v: 0.3208
|
||||
u': 0.2016 v': 0.4813
|
||||
|
||||
--- CRI ---
|
||||
Ra: 82.9 SDCM: 4.84
|
||||
R1=82, R2=94, R3=94, R4=77, R5=81, R6=89, R7=83, R8=64
|
||||
R9=4, R10=84, R11=76, R12=60, R13=86, R14=97, R15=76
|
||||
|
||||
--- CIE 1931 ---
|
||||
X: 667.491 Y: 708.254 Z: 651.135
|
||||
TLCI-2012: 68
|
||||
|
||||
--- Radiometric ---
|
||||
Phi_e (mW): 1501.374
|
||||
Phi_eb (mW): 471.605
|
||||
Phi_ey (mW): 686.214
|
||||
Phi_er (mW): 333.961
|
||||
Phi_efr (mW): 11.231
|
||||
|
||||
--- Electrical ---
|
||||
Voltage (V): 230.29
|
||||
Current (A): 0.0654
|
||||
Power (W): 8.407
|
||||
Frequency (Hz): 50.02
|
||||
Power Factor: 0.5584
|
||||
|
||||
--- Sensor ---
|
||||
Peak Signal: 53279
|
||||
Dark Signal: 2154
|
||||
Compensate: 2834
|
||||
|
||||
--- Spectrum (380-1050nm) ---
|
||||
380.0nm: 0.0005 []
|
||||
389.6nm: 0.0876 []
|
||||
399.2nm: 0.3179 []
|
||||
408.8nm: 0.7661 [##]
|
||||
418.4nm: 1.6232 [####]
|
||||
428.0nm: 3.0322 [########]
|
||||
437.6nm: 5.2464 [###############]
|
||||
447.2nm: 8.7276 [########################]
|
||||
456.8nm: 12.7905 [####################################]
|
||||
466.4nm: 13.6955 [#######################################]
|
||||
476.0nm: 10.7966 [##############################]
|
||||
485.6nm: 8.2842 [#######################]
|
||||
495.2nm: 6.7507 [###################]
|
||||
504.8nm: 5.4679 [###############]
|
||||
514.4nm: 4.6912 [#############]
|
||||
524.0nm: 4.4910 [############]
|
||||
533.6nm: 4.6211 [#############]
|
||||
543.2nm: 4.9149 [##############]
|
||||
552.8nm: 5.3253 [###############]
|
||||
562.4nm: 5.7463 [################]
|
||||
572.0nm: 6.0899 [#################]
|
||||
581.6nm: 6.3225 [##################]
|
||||
591.2nm: 6.4763 [##################]
|
||||
600.8nm: 6.5981 [##################]
|
||||
610.4nm: 6.7011 [###################]
|
||||
620.0nm: 6.8460 [###################]
|
||||
629.6nm: 6.9854 [####################]
|
||||
639.2nm: 7.1199 [####################]
|
||||
648.8nm: 7.2257 [####################]
|
||||
658.4nm: 7.2971 [####################]
|
||||
668.0nm: 7.3692 [#####################]
|
||||
677.6nm: 7.4499 [#####################]
|
||||
687.2nm: 7.5612 [#####################]
|
||||
696.8nm: 7.5899 [#####################]
|
||||
706.4nm: 7.5444 [#####################]
|
||||
716.0nm: 7.4506 [#####################]
|
||||
725.6nm: 7.2796 [####################]
|
||||
735.2nm: 7.0827 [####################]
|
||||
744.8nm: 6.7888 [###################]
|
||||
754.4nm: 6.4305 [##################]
|
||||
764.0nm: 6.0190 [#################]
|
||||
773.6nm: 5.6257 [################]
|
||||
783.2nm: 5.1849 [##############]
|
||||
792.8nm: 4.7472 [#############]
|
||||
802.3nm: 4.3186 [############]
|
||||
811.9nm: 3.9139 [###########]
|
||||
821.5nm: 3.4785 [#########]
|
||||
831.1nm: 3.0552 [########]
|
||||
840.7nm: 2.6780 [#######]
|
||||
850.3nm: 2.3105 [######]
|
||||
859.9nm: 2.0011 [#####]
|
||||
869.5nm: 1.7210 [####]
|
||||
879.1nm: 1.4606 [####]
|
||||
888.7nm: 1.2518 [###]
|
||||
898.3nm: 1.0460 [##]
|
||||
907.9nm: 0.8661 [##]
|
||||
917.5nm: 0.7120 [##]
|
||||
927.1nm: 0.5761 [#]
|
||||
936.7nm: 0.4697 [#]
|
||||
946.3nm: 0.3787 [#]
|
||||
955.9nm: 0.3071 []
|
||||
965.5nm: 0.2300 []
|
||||
975.1nm: 0.1782 []
|
||||
984.7nm: 0.1421 []
|
||||
994.3nm: 0.1106 []
|
||||
1003.9nm: 0.0824 []
|
||||
1013.5nm: 0.0610 []
|
||||
1023.1nm: 0.0405 []
|
||||
1032.7nm: 0.0208 []
|
||||
1042.3nm: 0.0087 []
|
||||
@@ -0,0 +1,43 @@
|
||||
Device: HPCS6500
|
||||
Date: 2026-02-04 Time: 16:29:39
|
||||
|
||||
--- Photometric ---
|
||||
Phi (lm): 479.59
|
||||
eta (lm/W): 57.04
|
||||
CCT (K): 5661
|
||||
Duv: 0.00550
|
||||
|
||||
--- Chromaticity ---
|
||||
x: 0.3287 y: 0.3486
|
||||
u: 0.2015 v: 0.3205
|
||||
u': 0.2015 v': 0.4808
|
||||
|
||||
--- CRI ---
|
||||
Ra: 83.0 SDCM: 4.66
|
||||
R1=82, R2=94, R3=93, R4=77, R5=82, R6=90, R7=83, R8=64
|
||||
R9=5, R10=84, R11=76, R12=60, R13=86, R14=97, R15=76
|
||||
|
||||
--- CIE 1931 ---
|
||||
X: 662.004 Y: 702.182 Z: 649.858
|
||||
TLCI-2012: 68
|
||||
|
||||
--- Radiometric ---
|
||||
Phi_e (mW): 1492.026
|
||||
Phi_eb (mW): 470.266
|
||||
Phi_ey (mW): 679.672
|
||||
Phi_er (mW): 330.604
|
||||
Phi_efr (mW): 11.206
|
||||
|
||||
--- Electrical ---
|
||||
Voltage (V): 230.29
|
||||
Current (A): 0.0653
|
||||
Power (W): 8.405
|
||||
Frequency (Hz): 50.02
|
||||
Power Factor: 0.5586
|
||||
UThd (%): 0.8113
|
||||
AThd (%): 109.0115
|
||||
|
||||
--- Sensor ---
|
||||
Peak Signal: 53165
|
||||
Dark Signal: 2310
|
||||
Compensate: 2834
|
||||
@@ -0,0 +1,218 @@
|
||||
Device: HPCS6500
|
||||
Date: 2026-02-04 Time: 16:29:39
|
||||
|
||||
--- Photometric ---
|
||||
Phi (lm): 479.59
|
||||
eta (lm/W): 57.04
|
||||
CCT (K): 5661
|
||||
Duv: 0.00550
|
||||
|
||||
--- Chromaticity ---
|
||||
x: 0.3287 y: 0.3486
|
||||
u: 0.2015 v: 0.3205
|
||||
u': 0.2015 v': 0.4808
|
||||
|
||||
--- CRI ---
|
||||
Ra: 83.0 SDCM: 4.66
|
||||
R1=82, R2=94, R3=93, R4=77, R5=82, R6=90, R7=83, R8=64
|
||||
R9=5, R10=84, R11=76, R12=60, R13=86, R14=97, R15=76
|
||||
|
||||
--- CIE 1931 ---
|
||||
X: 662.004 Y: 702.182 Z: 649.858
|
||||
TLCI-2012: 68
|
||||
|
||||
--- Radiometric ---
|
||||
Phi_e (mW): 1492.026
|
||||
Phi_eb (mW): 470.266
|
||||
Phi_ey (mW): 679.672
|
||||
Phi_er (mW): 330.604
|
||||
Phi_efr (mW): 11.206
|
||||
|
||||
--- Electrical ---
|
||||
Voltage (V): 230.29
|
||||
Current (A): 0.0653
|
||||
Power (W): 8.405
|
||||
Frequency (Hz): 50.02
|
||||
Power Factor: 0.5586
|
||||
UThd (%): 0.8113
|
||||
AThd (%): 109.0115
|
||||
|
||||
--- Voltage Harmonics (%) ---
|
||||
H 1: 100.0000
|
||||
H 2: 0.3370
|
||||
H 3: 0.4587
|
||||
H 4: 0.1953
|
||||
H 5: 0.1626
|
||||
H 6: 0.1061
|
||||
H 7: 0.1800
|
||||
H 8: 0.0513
|
||||
H 9: 0.2902
|
||||
H10: 0.0447
|
||||
H11: 0.2477
|
||||
H12: 0.0023
|
||||
H13: 0.1861
|
||||
H14: 0.0063
|
||||
H15: 0.1183
|
||||
H16: 0.0155
|
||||
H17: 0.1239
|
||||
H18: 0.0149
|
||||
H19: 0.0590
|
||||
H20: 0.0080
|
||||
H21: 0.0465
|
||||
H23: 0.0217
|
||||
H24: 0.0079
|
||||
H25: 0.0185
|
||||
H26: 0.0076
|
||||
H27: 0.0265
|
||||
H28: 0.0027
|
||||
H29: 0.0136
|
||||
H30: 0.0066
|
||||
H31: 0.0275
|
||||
H32: 0.0042
|
||||
H33: 0.0146
|
||||
H34: 0.0129
|
||||
H35: 0.0164
|
||||
H36: 0.0080
|
||||
H37: 0.0128
|
||||
H38: 0.0066
|
||||
H39: 0.0194
|
||||
H40: 0.0092
|
||||
H41: 0.0086
|
||||
H42: 0.0019
|
||||
H43: 0.0170
|
||||
H44: 0.0057
|
||||
H45: 0.0138
|
||||
H46: 0.0013
|
||||
H47: 0.0083
|
||||
H48: 0.0043
|
||||
H49: 0.0266
|
||||
H50: 0.0067
|
||||
|
||||
--- Current Harmonics (%) ---
|
||||
H 1: 100.0000
|
||||
H 2: 1.1245
|
||||
H 3: 77.3376
|
||||
H 4: 1.3714
|
||||
H 5: 54.7943
|
||||
H 6: 1.2291
|
||||
H 7: 35.3840
|
||||
H 8: 1.1566
|
||||
H 9: 25.6305
|
||||
H10: 1.3803
|
||||
H11: 20.6588
|
||||
H12: 1.3435
|
||||
H13: 14.4722
|
||||
H14: 0.8883
|
||||
H15: 8.2279
|
||||
H16: 0.4150
|
||||
H17: 5.8570
|
||||
H18: 0.4188
|
||||
H19: 5.5059
|
||||
H20: 0.6645
|
||||
H21: 5.3454
|
||||
H22: 0.5912
|
||||
H23: 5.7325
|
||||
H24: 0.5132
|
||||
H25: 5.0631
|
||||
H26: 0.5359
|
||||
H27: 4.0568
|
||||
H28: 0.6665
|
||||
H29: 3.8878
|
||||
H30: 0.6506
|
||||
H31: 3.9866
|
||||
H32: 0.6108
|
||||
H33: 3.3681
|
||||
H34: 0.5727
|
||||
H35: 2.7296
|
||||
H36: 0.6597
|
||||
H37: 2.9302
|
||||
H38: 0.6761
|
||||
H39: 2.9201
|
||||
H40: 0.5632
|
||||
H41: 2.7267
|
||||
H42: 0.5785
|
||||
H43: 2.7861
|
||||
H44: 0.6036
|
||||
H45: 2.7887
|
||||
H46: 0.6251
|
||||
H47: 2.5635
|
||||
H48: 0.5767
|
||||
H49: 2.3997
|
||||
H50: 0.5808
|
||||
|
||||
--- Sensor ---
|
||||
Peak Signal: 53165
|
||||
Dark Signal: 2310
|
||||
Compensate: 2834
|
||||
|
||||
--- Spectrum (380-1050nm) ---
|
||||
380.0nm: 0.0001 []
|
||||
389.6nm: 0.0770 []
|
||||
399.2nm: 0.3100 []
|
||||
408.8nm: 0.7661 [##]
|
||||
418.4nm: 1.6114 [####]
|
||||
428.0nm: 3.0064 [########]
|
||||
437.6nm: 5.1990 [###############]
|
||||
447.2nm: 8.6082 [########################]
|
||||
456.8nm: 12.6327 [####################################]
|
||||
466.4nm: 13.6560 [#######################################]
|
||||
476.0nm: 10.8536 [###############################]
|
||||
485.6nm: 8.3290 [########################]
|
||||
495.2nm: 6.7889 [###################]
|
||||
504.8nm: 5.5147 [###############]
|
||||
514.4nm: 4.7041 [#############]
|
||||
524.0nm: 4.4857 [############]
|
||||
533.6nm: 4.6031 [#############]
|
||||
543.2nm: 4.8814 [##############]
|
||||
552.8nm: 5.2789 [###############]
|
||||
562.4nm: 5.6901 [################]
|
||||
572.0nm: 6.0276 [#################]
|
||||
581.6nm: 6.2592 [##################]
|
||||
591.2nm: 6.4122 [##################]
|
||||
600.8nm: 6.5277 [##################]
|
||||
610.4nm: 6.6358 [###################]
|
||||
620.0nm: 6.7792 [###################]
|
||||
629.6nm: 6.9254 [###################]
|
||||
639.2nm: 7.0578 [####################]
|
||||
648.8nm: 7.1569 [####################]
|
||||
658.4nm: 7.2319 [####################]
|
||||
668.0nm: 7.2977 [#####################]
|
||||
677.6nm: 7.3732 [#####################]
|
||||
687.2nm: 7.4891 [#####################]
|
||||
696.8nm: 7.5217 [#####################]
|
||||
706.4nm: 7.4749 [#####################]
|
||||
716.0nm: 7.3825 [#####################]
|
||||
725.6nm: 7.2120 [####################]
|
||||
735.2nm: 7.0133 [####################]
|
||||
744.8nm: 6.7207 [###################]
|
||||
754.4nm: 6.3674 [##################]
|
||||
764.0nm: 5.9602 [#################]
|
||||
773.6nm: 5.5667 [################]
|
||||
783.2nm: 5.1329 [##############]
|
||||
792.8nm: 4.7013 [#############]
|
||||
802.3nm: 4.2745 [############]
|
||||
811.9nm: 3.8716 [###########]
|
||||
821.5nm: 3.4390 [#########]
|
||||
831.1nm: 3.0240 [########]
|
||||
840.7nm: 2.6514 [#######]
|
||||
850.3nm: 2.2870 [######]
|
||||
859.9nm: 1.9779 [#####]
|
||||
869.5nm: 1.7045 [####]
|
||||
879.1nm: 1.4498 [####]
|
||||
888.7nm: 1.2401 [###]
|
||||
898.3nm: 1.0367 [##]
|
||||
907.9nm: 0.8554 [##]
|
||||
917.5nm: 0.7071 [##]
|
||||
927.1nm: 0.5781 [#]
|
||||
936.7nm: 0.4678 [#]
|
||||
946.3nm: 0.3798 [#]
|
||||
955.9nm: 0.3085 []
|
||||
965.5nm: 0.2315 []
|
||||
975.1nm: 0.1769 []
|
||||
984.7nm: 0.1405 []
|
||||
994.3nm: 0.1102 []
|
||||
1003.9nm: 0.0810 []
|
||||
1013.5nm: 0.0582 []
|
||||
1023.1nm: 0.0389 []
|
||||
1032.7nm: 0.0225 []
|
||||
1042.3nm: 0.0079 []
|
||||
@@ -0,0 +1,43 @@
|
||||
Device: HPCS6500
|
||||
Date: 2026-02-04 Time: 16:50:38
|
||||
|
||||
--- Photometric ---
|
||||
Phi (lm): 0.00
|
||||
eta (lm/W): 0.00
|
||||
CCT (K): 0
|
||||
Duv: 0.00000
|
||||
|
||||
--- Chromaticity ---
|
||||
x: 0.0000 y: 0.0000
|
||||
u: 0.0000 v: 0.0000
|
||||
u': 0.0000 v': 0.0000
|
||||
|
||||
--- CRI ---
|
||||
Ra: 0.0 SDCM: 0.00
|
||||
R1=0, R2=0, R3=0, R4=0, R5=0, R6=0, R7=0, R8=0
|
||||
R9=0, R10=0, R11=0, R12=0, R13=0, R14=0, R15=0
|
||||
|
||||
--- CIE 1931 ---
|
||||
X: 0.000 Y: 0.000 Z: 0.000
|
||||
TLCI-2012: 0
|
||||
|
||||
--- Radiometric ---
|
||||
Phi_e (mW): 0.000
|
||||
Phi_eb (mW): 0.000
|
||||
Phi_ey (mW): 0.000
|
||||
Phi_er (mW): 0.000
|
||||
Phi_efr (mW): 0.000
|
||||
|
||||
--- Electrical ---
|
||||
Voltage (V): 0.00
|
||||
Current (A): 0.0000
|
||||
Power (W): 0.000
|
||||
Frequency (Hz): 0.00
|
||||
Power Factor: 0.0000
|
||||
UThd (%): 0.8692
|
||||
AThd (%): 116.7091
|
||||
|
||||
--- Sensor ---
|
||||
Peak Signal: 0
|
||||
Dark Signal: 0
|
||||
Compensate: 0
|
||||
@@ -0,0 +1,219 @@
|
||||
Device: HPCS6500
|
||||
Date: 2026-02-04 Time: 16:50:38
|
||||
|
||||
--- Photometric ---
|
||||
Phi (lm): 0.00
|
||||
eta (lm/W): 0.00
|
||||
CCT (K): 0
|
||||
Duv: 0.00000
|
||||
|
||||
--- Chromaticity ---
|
||||
x: 0.0000 y: 0.0000
|
||||
u: 0.0000 v: 0.0000
|
||||
u': 0.0000 v': 0.0000
|
||||
|
||||
--- CRI ---
|
||||
Ra: 0.0 SDCM: 0.00
|
||||
R1=0, R2=0, R3=0, R4=0, R5=0, R6=0, R7=0, R8=0
|
||||
R9=0, R10=0, R11=0, R12=0, R13=0, R14=0, R15=0
|
||||
|
||||
--- CIE 1931 ---
|
||||
X: 0.000 Y: 0.000 Z: 0.000
|
||||
TLCI-2012: 0
|
||||
|
||||
--- Radiometric ---
|
||||
Phi_e (mW): 0.000
|
||||
Phi_eb (mW): 0.000
|
||||
Phi_ey (mW): 0.000
|
||||
Phi_er (mW): 0.000
|
||||
Phi_efr (mW): 0.000
|
||||
|
||||
--- Electrical ---
|
||||
Voltage (V): 0.00
|
||||
Current (A): 0.0000
|
||||
Power (W): 0.000
|
||||
Frequency (Hz): 0.00
|
||||
Power Factor: 0.0000
|
||||
UThd (%): 0.8692
|
||||
AThd (%): 116.7091
|
||||
|
||||
--- Voltage Harmonics (%) ---
|
||||
H 1: 100.0000
|
||||
H 2: 0.3466
|
||||
H 3: 0.5529
|
||||
H 4: 0.1935
|
||||
H 5: 0.1009
|
||||
H 6: 0.1319
|
||||
H 7: 0.1455
|
||||
H 8: 0.0709
|
||||
H 9: 0.3610
|
||||
H10: 0.0452
|
||||
H11: 0.2558
|
||||
H12: 0.0099
|
||||
H13: 0.1471
|
||||
H14: 0.0182
|
||||
H15: 0.0881
|
||||
H16: 0.0119
|
||||
H17: 0.0439
|
||||
H18: 0.0054
|
||||
H19: 0.0441
|
||||
H20: 0.0039
|
||||
H21: 0.0444
|
||||
H22: 0.0038
|
||||
H23: 0.0419
|
||||
H24: 0.0070
|
||||
H25: 0.0277
|
||||
H26: 0.0086
|
||||
H27: 0.0180
|
||||
H28: 0.0038
|
||||
H29: 0.0168
|
||||
H30: 0.0031
|
||||
H31: 0.0085
|
||||
H32: 0.0042
|
||||
H33: 0.0141
|
||||
H34: 0.0039
|
||||
H35: 0.0050
|
||||
H36: 0.0076
|
||||
H37: 0.0172
|
||||
H38: 0.0017
|
||||
H39: 0.0135
|
||||
H40: 0.0122
|
||||
H41: 0.0075
|
||||
H42: 0.0091
|
||||
H43: 0.0079
|
||||
H44: 0.0052
|
||||
H45: 0.0028
|
||||
H46: 0.0063
|
||||
H47: 0.0106
|
||||
H48: 0.0055
|
||||
H49: 0.0066
|
||||
H50: 0.0032
|
||||
|
||||
--- Current Harmonics (%) ---
|
||||
H 1: 100.0000
|
||||
H 2: 1.3069
|
||||
H 3: 79.6826
|
||||
H 4: 1.6782
|
||||
H 5: 62.0099
|
||||
H 6: 1.8375
|
||||
H 7: 43.0251
|
||||
H 8: 1.4561
|
||||
H 9: 27.1434
|
||||
H10: 0.8638
|
||||
H11: 16.3288
|
||||
H12: 0.8914
|
||||
H13: 10.5836
|
||||
H14: 0.8186
|
||||
H15: 7.8296
|
||||
H16: 0.6092
|
||||
H17: 7.1998
|
||||
H18: 0.5320
|
||||
H19: 6.8390
|
||||
H20: 0.7901
|
||||
H21: 5.9471
|
||||
H22: 0.8641
|
||||
H23: 5.4131
|
||||
H24: 0.7893
|
||||
H25: 5.4738
|
||||
H26: 0.6972
|
||||
H27: 5.4525
|
||||
H28: 0.6923
|
||||
H29: 4.8222
|
||||
H30: 0.7761
|
||||
H31: 4.3909
|
||||
H32: 0.7792
|
||||
H33: 4.3903
|
||||
H34: 0.7458
|
||||
H35: 4.3602
|
||||
H36: 0.7061
|
||||
H37: 3.9436
|
||||
H38: 0.6865
|
||||
H39: 3.6134
|
||||
H40: 0.6928
|
||||
H41: 3.4965
|
||||
H42: 0.7969
|
||||
H43: 3.1709
|
||||
H44: 0.7282
|
||||
H45: 2.8721
|
||||
H46: 0.6770
|
||||
H47: 2.6774
|
||||
H48: 0.6486
|
||||
H49: 2.6343
|
||||
H50: 0.6855
|
||||
|
||||
--- Sensor ---
|
||||
Peak Signal: 0
|
||||
Dark Signal: 0
|
||||
Compensate: 0
|
||||
|
||||
--- Spectrum (380-1050nm) ---
|
||||
380.0nm: 0.0000 []
|
||||
389.6nm: 0.0000 []
|
||||
399.2nm: 0.0000 []
|
||||
408.8nm: 0.0000 []
|
||||
418.4nm: 0.0000 []
|
||||
428.0nm: 0.0000 []
|
||||
437.6nm: 0.0000 []
|
||||
447.2nm: 0.0000 []
|
||||
456.8nm: 0.0000 []
|
||||
466.4nm: 0.0000 []
|
||||
476.0nm: 0.0000 []
|
||||
485.6nm: 0.0000 []
|
||||
495.2nm: 0.0000 []
|
||||
504.8nm: 0.0000 []
|
||||
514.4nm: 0.0000 []
|
||||
524.0nm: 0.0000 []
|
||||
533.6nm: 0.0000 []
|
||||
543.2nm: 0.0000 []
|
||||
552.8nm: 0.0000 []
|
||||
562.4nm: 0.0000 []
|
||||
572.0nm: 0.0000 []
|
||||
581.6nm: 0.0000 []
|
||||
591.2nm: 0.0000 []
|
||||
600.8nm: 0.0000 []
|
||||
610.4nm: 0.0000 []
|
||||
620.0nm: 0.0000 []
|
||||
629.6nm: 0.0000 []
|
||||
639.2nm: 0.0000 []
|
||||
648.8nm: 0.0000 []
|
||||
658.4nm: 0.0000 []
|
||||
668.0nm: 0.0000 []
|
||||
677.6nm: 0.0000 []
|
||||
687.2nm: 0.0000 []
|
||||
696.8nm: 0.0000 []
|
||||
706.4nm: 0.0000 []
|
||||
716.0nm: 0.0000 []
|
||||
725.6nm: 0.0000 []
|
||||
735.2nm: 0.0000 []
|
||||
744.8nm: 0.0000 []
|
||||
754.4nm: 0.0000 []
|
||||
764.0nm: 0.0000 []
|
||||
773.6nm: 0.0000 []
|
||||
783.2nm: 0.0000 []
|
||||
792.8nm: 0.0000 []
|
||||
802.3nm: 0.0000 []
|
||||
811.9nm: 0.0000 []
|
||||
821.5nm: 0.0000 []
|
||||
831.1nm: 0.0000 []
|
||||
840.7nm: 0.0000 []
|
||||
850.3nm: 0.0000 []
|
||||
859.9nm: 0.0000 []
|
||||
869.5nm: 0.0000 []
|
||||
879.1nm: 0.0000 []
|
||||
888.7nm: 0.0000 []
|
||||
898.3nm: 0.0000 []
|
||||
907.9nm: 0.0000 []
|
||||
917.5nm: 0.0000 []
|
||||
927.1nm: 0.0000 []
|
||||
936.7nm: 0.0000 []
|
||||
946.3nm: 0.0000 []
|
||||
955.9nm: 0.0000 []
|
||||
965.5nm: 0.0000 []
|
||||
975.1nm: 0.0000 []
|
||||
984.7nm: 0.0000 []
|
||||
994.3nm: 0.0000 []
|
||||
1003.9nm: 0.0000 []
|
||||
1013.5nm: 0.0000 []
|
||||
1023.1nm: 0.0000 []
|
||||
1032.7nm: 0.0000 []
|
||||
1042.3nm: 0.0000 []
|
||||
@@ -0,0 +1,41 @@
|
||||
Device: HPCS6500
|
||||
Date: 2026-02-04 Time: 15:58:53
|
||||
|
||||
--- Photometric ---
|
||||
Phi (lm): 479.83
|
||||
eta (lm/W): 57.09
|
||||
CCT (K): 5656
|
||||
Duv: 0.00552
|
||||
|
||||
--- Chromaticity ---
|
||||
x: 0.3288 y: 0.3488
|
||||
u: 0.2015 v: 0.3206
|
||||
u': 0.2015 v': 0.4809
|
||||
|
||||
--- CRI ---
|
||||
Ra: 83.0 SDCM: 4.70
|
||||
R1=82, R2=94, R3=93, R4=77, R5=81, R6=89, R7=83, R8=64
|
||||
R9=5, R10=84, R11=76, R12=60, R13=87, R14=97, R15=76
|
||||
|
||||
--- CIE 1931 ---
|
||||
X: 662.277 Y: 702.533 Z: 649.290
|
||||
TLCI-2012: 68
|
||||
|
||||
--- Radiometric ---
|
||||
Phi_e (mW): 1491.942
|
||||
Phi_eb (mW): 469.864
|
||||
Phi_ey (mW): 679.651
|
||||
Phi_er (mW): 330.758
|
||||
Phi_efr (mW): 11.142
|
||||
|
||||
--- Electrical ---
|
||||
Voltage (V): 230.30
|
||||
Current (A): 0.0653
|
||||
Power (W): 8.405
|
||||
Frequency (Hz): 50.02
|
||||
Power Factor: 0.5587
|
||||
|
||||
--- Sensor ---
|
||||
Peak Signal: 53051
|
||||
Dark Signal: 2228
|
||||
Compensate: 2834
|
||||
@@ -0,0 +1,113 @@
|
||||
Device: HPCS6500
|
||||
Date: 2026-02-04 Time: 15:58:53
|
||||
|
||||
--- Photometric ---
|
||||
Phi (lm): 479.83
|
||||
eta (lm/W): 57.09
|
||||
CCT (K): 5656
|
||||
Duv: 0.00552
|
||||
|
||||
--- Chromaticity ---
|
||||
x: 0.3288 y: 0.3488
|
||||
u: 0.2015 v: 0.3206
|
||||
u': 0.2015 v': 0.4809
|
||||
|
||||
--- CRI ---
|
||||
Ra: 83.0 SDCM: 4.70
|
||||
R1=82, R2=94, R3=93, R4=77, R5=81, R6=89, R7=83, R8=64
|
||||
R9=5, R10=84, R11=76, R12=60, R13=87, R14=97, R15=76
|
||||
|
||||
--- CIE 1931 ---
|
||||
X: 662.277 Y: 702.533 Z: 649.290
|
||||
TLCI-2012: 68
|
||||
|
||||
--- Radiometric ---
|
||||
Phi_e (mW): 1491.942
|
||||
Phi_eb (mW): 469.864
|
||||
Phi_ey (mW): 679.651
|
||||
Phi_er (mW): 330.758
|
||||
Phi_efr (mW): 11.142
|
||||
|
||||
--- Electrical ---
|
||||
Voltage (V): 230.30
|
||||
Current (A): 0.0653
|
||||
Power (W): 8.405
|
||||
Frequency (Hz): 50.02
|
||||
Power Factor: 0.5587
|
||||
|
||||
--- Sensor ---
|
||||
Peak Signal: 53051
|
||||
Dark Signal: 2228
|
||||
Compensate: 2834
|
||||
|
||||
--- Spectrum (380-1050nm) ---
|
||||
380.0nm: 0.0000 []
|
||||
389.6nm: 0.0838 []
|
||||
399.2nm: 0.3137 []
|
||||
408.8nm: 0.7712 [##]
|
||||
418.4nm: 1.6204 [####]
|
||||
428.0nm: 3.0103 [########]
|
||||
437.6nm: 5.1952 [###############]
|
||||
447.2nm: 8.5932 [########################]
|
||||
456.8nm: 12.6190 [####################################]
|
||||
466.4nm: 13.6409 [#######################################]
|
||||
476.0nm: 10.8421 [###############################]
|
||||
485.6nm: 8.3131 [########################]
|
||||
495.2nm: 6.7764 [###################]
|
||||
504.8nm: 5.5052 [###############]
|
||||
514.4nm: 4.6985 [#############]
|
||||
524.0nm: 4.4838 [############]
|
||||
533.6nm: 4.5985 [#############]
|
||||
543.2nm: 4.8779 [##############]
|
||||
552.8nm: 5.2744 [###############]
|
||||
562.4nm: 5.6922 [################]
|
||||
572.0nm: 6.0260 [#################]
|
||||
581.6nm: 6.2605 [##################]
|
||||
591.2nm: 6.4065 [##################]
|
||||
600.8nm: 6.5303 [##################]
|
||||
610.4nm: 6.6307 [###################]
|
||||
620.0nm: 6.7723 [###################]
|
||||
629.6nm: 6.9229 [###################]
|
||||
639.2nm: 7.0529 [####################]
|
||||
648.8nm: 7.1562 [####################]
|
||||
658.4nm: 7.2298 [####################]
|
||||
668.0nm: 7.2981 [#####################]
|
||||
677.6nm: 7.3778 [#####################]
|
||||
687.2nm: 7.4896 [#####################]
|
||||
696.8nm: 7.5259 [#####################]
|
||||
706.4nm: 7.4780 [#####################]
|
||||
716.0nm: 7.3868 [#####################]
|
||||
725.6nm: 7.2144 [####################]
|
||||
735.2nm: 7.0180 [####################]
|
||||
744.8nm: 6.7219 [###################]
|
||||
754.4nm: 6.3660 [##################]
|
||||
764.0nm: 5.9605 [#################]
|
||||
773.6nm: 5.5733 [################]
|
||||
783.2nm: 5.1339 [##############]
|
||||
792.8nm: 4.7023 [#############]
|
||||
802.3nm: 4.2764 [############]
|
||||
811.9nm: 3.8736 [###########]
|
||||
821.5nm: 3.4459 [#########]
|
||||
831.1nm: 3.0290 [########]
|
||||
840.7nm: 2.6555 [#######]
|
||||
850.3nm: 2.2888 [######]
|
||||
859.9nm: 1.9765 [#####]
|
||||
869.5nm: 1.7011 [####]
|
||||
879.1nm: 1.4485 [####]
|
||||
888.7nm: 1.2418 [###]
|
||||
898.3nm: 1.0371 [##]
|
||||
907.9nm: 0.8565 [##]
|
||||
917.5nm: 0.7085 [##]
|
||||
927.1nm: 0.5742 [#]
|
||||
936.7nm: 0.4662 [#]
|
||||
946.3nm: 0.3787 [#]
|
||||
955.9nm: 0.3092 []
|
||||
965.5nm: 0.2320 []
|
||||
975.1nm: 0.1777 []
|
||||
984.7nm: 0.1411 []
|
||||
994.3nm: 0.1086 []
|
||||
1003.9nm: 0.0792 []
|
||||
1013.5nm: 0.0565 []
|
||||
1023.1nm: 0.0397 []
|
||||
1032.7nm: 0.0197 []
|
||||
1042.3nm: 0.0046 []
|
||||
@@ -0,0 +1,41 @@
|
||||
Device: HPCS6500
|
||||
Date: 2026-02-04 Time: 16:04:22
|
||||
|
||||
--- Photometric ---
|
||||
Phi (lm): 479.62
|
||||
eta (lm/W): 57.10
|
||||
CCT (K): 5655
|
||||
Duv: 0.00551
|
||||
|
||||
--- Chromaticity ---
|
||||
x: 0.3288 y: 0.3488
|
||||
u: 0.2015 v: 0.3206
|
||||
u': 0.2015 v': 0.4809
|
||||
|
||||
--- CRI ---
|
||||
Ra: 83.1 SDCM: 4.69
|
||||
R1=82, R2=94, R3=93, R4=76, R5=81, R6=90, R7=83, R8=64
|
||||
R9=5, R10=84, R11=76, R12=60, R13=87, R14=97, R15=76
|
||||
|
||||
--- CIE 1931 ---
|
||||
X: 662.007 Y: 702.221 Z: 648.987
|
||||
TLCI-2012: 68
|
||||
|
||||
--- Radiometric ---
|
||||
Phi_e (mW): 1491.544
|
||||
Phi_eb (mW): 469.850
|
||||
Phi_ey (mW): 679.956
|
||||
Phi_er (mW): 331.674
|
||||
Phi_efr (mW): 11.563
|
||||
|
||||
--- Electrical ---
|
||||
Voltage (V): 230.29
|
||||
Current (A): 0.0653
|
||||
Power (W): 8.400
|
||||
Frequency (Hz): 50.02
|
||||
Power Factor: 0.5586
|
||||
|
||||
--- Sensor ---
|
||||
Peak Signal: 53066
|
||||
Dark Signal: 2236
|
||||
Compensate: 2834
|
||||
@@ -0,0 +1,113 @@
|
||||
Device: HPCS6500
|
||||
Date: 2026-02-04 Time: 16:04:22
|
||||
|
||||
--- Photometric ---
|
||||
Phi (lm): 479.62
|
||||
eta (lm/W): 57.10
|
||||
CCT (K): 5655
|
||||
Duv: 0.00551
|
||||
|
||||
--- Chromaticity ---
|
||||
x: 0.3288 y: 0.3488
|
||||
u: 0.2015 v: 0.3206
|
||||
u': 0.2015 v': 0.4809
|
||||
|
||||
--- CRI ---
|
||||
Ra: 83.1 SDCM: 4.69
|
||||
R1=82, R2=94, R3=93, R4=76, R5=81, R6=90, R7=83, R8=64
|
||||
R9=5, R10=84, R11=76, R12=60, R13=87, R14=97, R15=76
|
||||
|
||||
--- CIE 1931 ---
|
||||
X: 662.007 Y: 702.221 Z: 648.987
|
||||
TLCI-2012: 68
|
||||
|
||||
--- Radiometric ---
|
||||
Phi_e (mW): 1491.544
|
||||
Phi_eb (mW): 469.850
|
||||
Phi_ey (mW): 679.956
|
||||
Phi_er (mW): 331.674
|
||||
Phi_efr (mW): 11.563
|
||||
|
||||
--- Electrical ---
|
||||
Voltage (V): 230.29
|
||||
Current (A): 0.0653
|
||||
Power (W): 8.400
|
||||
Frequency (Hz): 50.02
|
||||
Power Factor: 0.5586
|
||||
|
||||
--- Sensor ---
|
||||
Peak Signal: 53066
|
||||
Dark Signal: 2236
|
||||
Compensate: 2834
|
||||
|
||||
--- Spectrum (380-1050nm) ---
|
||||
380.0nm: 0.0004 []
|
||||
389.6nm: 0.0896 []
|
||||
399.2nm: 0.3198 []
|
||||
408.8nm: 0.7697 [##]
|
||||
418.4nm: 1.6099 [####]
|
||||
428.0nm: 2.9921 [########]
|
||||
437.6nm: 5.1750 [##############]
|
||||
447.2nm: 8.5520 [########################]
|
||||
456.8nm: 12.5835 [####################################]
|
||||
466.4nm: 13.6533 [#######################################]
|
||||
476.0nm: 10.8713 [###############################]
|
||||
485.6nm: 8.3388 [########################]
|
||||
495.2nm: 6.7961 [###################]
|
||||
504.8nm: 5.5178 [###############]
|
||||
514.4nm: 4.7100 [#############]
|
||||
524.0nm: 4.4835 [############]
|
||||
533.6nm: 4.5955 [#############]
|
||||
543.2nm: 4.8758 [##############]
|
||||
552.8nm: 5.2754 [###############]
|
||||
562.4nm: 5.6932 [################]
|
||||
572.0nm: 6.0259 [#################]
|
||||
581.6nm: 6.2639 [##################]
|
||||
591.2nm: 6.4078 [##################]
|
||||
600.8nm: 6.5338 [##################]
|
||||
610.4nm: 6.6373 [###################]
|
||||
620.0nm: 6.7757 [###################]
|
||||
629.6nm: 6.9260 [####################]
|
||||
639.2nm: 7.0568 [####################]
|
||||
648.8nm: 7.1664 [####################]
|
||||
658.4nm: 7.2312 [####################]
|
||||
668.0nm: 7.2992 [#####################]
|
||||
677.6nm: 7.3760 [#####################]
|
||||
687.2nm: 7.4918 [#####################]
|
||||
696.8nm: 7.5267 [#####################]
|
||||
706.4nm: 7.4803 [#####################]
|
||||
716.0nm: 7.3913 [#####################]
|
||||
725.6nm: 7.2224 [####################]
|
||||
735.2nm: 7.0243 [####################]
|
||||
744.8nm: 6.7270 [###################]
|
||||
754.4nm: 6.3784 [##################]
|
||||
764.0nm: 5.9711 [#################]
|
||||
773.6nm: 5.5765 [################]
|
||||
783.2nm: 5.1446 [##############]
|
||||
792.8nm: 4.7115 [#############]
|
||||
802.3nm: 4.2878 [############]
|
||||
811.9nm: 3.8857 [###########]
|
||||
821.5nm: 3.4575 [#########]
|
||||
831.1nm: 3.0392 [########]
|
||||
840.7nm: 2.6646 [#######]
|
||||
850.3nm: 2.2973 [######]
|
||||
859.9nm: 1.9856 [#####]
|
||||
869.5nm: 1.7133 [####]
|
||||
879.1nm: 1.4529 [####]
|
||||
888.7nm: 1.2538 [###]
|
||||
898.3nm: 1.0436 [###]
|
||||
907.9nm: 0.8680 [##]
|
||||
917.5nm: 0.7157 [##]
|
||||
927.1nm: 0.5790 [#]
|
||||
936.7nm: 0.4731 [#]
|
||||
946.3nm: 0.3838 [#]
|
||||
955.9nm: 0.3139 []
|
||||
965.5nm: 0.2378 []
|
||||
975.1nm: 0.1854 []
|
||||
984.7nm: 0.1477 []
|
||||
994.3nm: 0.1152 []
|
||||
1003.9nm: 0.0853 []
|
||||
1013.5nm: 0.0633 []
|
||||
1023.1nm: 0.0463 []
|
||||
1032.7nm: 0.0285 []
|
||||
1042.3nm: 0.0129 []
|
||||
@@ -0,0 +1,41 @@
|
||||
Device: HPCS6500
|
||||
Date: 2026-02-04 Time: 16:04:31
|
||||
|
||||
--- Photometric ---
|
||||
Phi (lm): 479.61
|
||||
eta (lm/W): 57.05
|
||||
CCT (K): 5654
|
||||
Duv: 0.00553
|
||||
|
||||
--- Chromaticity ---
|
||||
x: 0.3288 y: 0.3488
|
||||
u: 0.2015 v: 0.3206
|
||||
u': 0.2015 v': 0.4809
|
||||
|
||||
--- CRI ---
|
||||
Ra: 83.0 SDCM: 4.71
|
||||
R1=82, R2=94, R3=93, R4=76, R5=81, R6=89, R7=83, R8=64
|
||||
R9=5, R10=84, R11=76, R12=60, R13=87, R14=97, R15=76
|
||||
|
||||
--- CIE 1931 ---
|
||||
X: 661.955 Y: 702.217 Z: 648.778
|
||||
TLCI-2012: 68
|
||||
|
||||
--- Radiometric ---
|
||||
Phi_e (mW): 1491.409
|
||||
Phi_eb (mW): 470.118
|
||||
Phi_ey (mW): 679.660
|
||||
Phi_er (mW): 331.025
|
||||
Phi_efr (mW): 11.807
|
||||
|
||||
--- Electrical ---
|
||||
Voltage (V): 230.30
|
||||
Current (A): 0.0653
|
||||
Power (W): 8.407
|
||||
Frequency (Hz): 50.02
|
||||
Power Factor: 0.5589
|
||||
|
||||
--- Sensor ---
|
||||
Peak Signal: 53066
|
||||
Dark Signal: 2246
|
||||
Compensate: 2834
|
||||
@@ -0,0 +1,113 @@
|
||||
Device: HPCS6500
|
||||
Date: 2026-02-04 Time: 16:04:31
|
||||
|
||||
--- Photometric ---
|
||||
Phi (lm): 479.61
|
||||
eta (lm/W): 57.05
|
||||
CCT (K): 5654
|
||||
Duv: 0.00553
|
||||
|
||||
--- Chromaticity ---
|
||||
x: 0.3288 y: 0.3488
|
||||
u: 0.2015 v: 0.3206
|
||||
u': 0.2015 v': 0.4809
|
||||
|
||||
--- CRI ---
|
||||
Ra: 83.0 SDCM: 4.71
|
||||
R1=82, R2=94, R3=93, R4=76, R5=81, R6=89, R7=83, R8=64
|
||||
R9=5, R10=84, R11=76, R12=60, R13=87, R14=97, R15=76
|
||||
|
||||
--- CIE 1931 ---
|
||||
X: 661.955 Y: 702.217 Z: 648.778
|
||||
TLCI-2012: 68
|
||||
|
||||
--- Radiometric ---
|
||||
Phi_e (mW): 1491.409
|
||||
Phi_eb (mW): 470.118
|
||||
Phi_ey (mW): 679.660
|
||||
Phi_er (mW): 331.025
|
||||
Phi_efr (mW): 11.807
|
||||
|
||||
--- Electrical ---
|
||||
Voltage (V): 230.30
|
||||
Current (A): 0.0653
|
||||
Power (W): 8.407
|
||||
Frequency (Hz): 50.02
|
||||
Power Factor: 0.5589
|
||||
|
||||
--- Sensor ---
|
||||
Peak Signal: 53066
|
||||
Dark Signal: 2246
|
||||
Compensate: 2834
|
||||
|
||||
--- Spectrum (380-1050nm) ---
|
||||
380.0nm: 0.0007 []
|
||||
389.6nm: 0.1039 []
|
||||
399.2nm: 0.3310 []
|
||||
408.8nm: 0.7735 [##]
|
||||
418.4nm: 1.6222 [####]
|
||||
428.0nm: 3.0048 [########]
|
||||
437.6nm: 5.1925 [##############]
|
||||
447.2nm: 8.5847 [########################]
|
||||
456.8nm: 12.6066 [####################################]
|
||||
466.4nm: 13.6462 [#######################################]
|
||||
476.0nm: 10.8482 [###############################]
|
||||
485.6nm: 8.3167 [########################]
|
||||
495.2nm: 6.7854 [###################]
|
||||
504.8nm: 5.5031 [###############]
|
||||
514.4nm: 4.7003 [#############]
|
||||
524.0nm: 4.4902 [############]
|
||||
533.6nm: 4.6031 [#############]
|
||||
543.2nm: 4.8753 [##############]
|
||||
552.8nm: 5.2749 [###############]
|
||||
562.4nm: 5.6866 [################]
|
||||
572.0nm: 6.0231 [#################]
|
||||
581.6nm: 6.2593 [##################]
|
||||
591.2nm: 6.4088 [##################]
|
||||
600.8nm: 6.5298 [##################]
|
||||
610.4nm: 6.6314 [###################]
|
||||
620.0nm: 6.7770 [###################]
|
||||
629.6nm: 6.9241 [###################]
|
||||
639.2nm: 7.0596 [####################]
|
||||
648.8nm: 7.1581 [####################]
|
||||
658.4nm: 7.2321 [####################]
|
||||
668.0nm: 7.2993 [#####################]
|
||||
677.6nm: 7.3692 [#####################]
|
||||
687.2nm: 7.4894 [#####################]
|
||||
696.8nm: 7.5227 [#####################]
|
||||
706.4nm: 7.4804 [#####################]
|
||||
716.0nm: 7.3870 [#####################]
|
||||
725.6nm: 7.2145 [####################]
|
||||
735.2nm: 7.0141 [####################]
|
||||
744.8nm: 6.7182 [###################]
|
||||
754.4nm: 6.3698 [##################]
|
||||
764.0nm: 5.9647 [#################]
|
||||
773.6nm: 5.5730 [################]
|
||||
783.2nm: 5.1334 [##############]
|
||||
792.8nm: 4.7031 [#############]
|
||||
802.3nm: 4.2776 [############]
|
||||
811.9nm: 3.8751 [###########]
|
||||
821.5nm: 3.4468 [#########]
|
||||
831.1nm: 3.0270 [########]
|
||||
840.7nm: 2.6591 [#######]
|
||||
850.3nm: 2.2925 [######]
|
||||
859.9nm: 1.9769 [#####]
|
||||
869.5nm: 1.7079 [####]
|
||||
879.1nm: 1.4541 [####]
|
||||
888.7nm: 1.2449 [###]
|
||||
898.3nm: 1.0430 [###]
|
||||
907.9nm: 0.8654 [##]
|
||||
917.5nm: 0.7167 [##]
|
||||
927.1nm: 0.5787 [#]
|
||||
936.7nm: 0.4744 [#]
|
||||
946.3nm: 0.3864 [#]
|
||||
955.9nm: 0.3180 []
|
||||
965.5nm: 0.2400 []
|
||||
975.1nm: 0.1895 []
|
||||
984.7nm: 0.1487 []
|
||||
994.3nm: 0.1197 []
|
||||
1003.9nm: 0.0899 []
|
||||
1013.5nm: 0.0693 []
|
||||
1023.1nm: 0.0503 []
|
||||
1032.7nm: 0.0345 []
|
||||
1042.3nm: 0.0189 []
|
||||
@@ -0,0 +1,41 @@
|
||||
Device: HPCS6500
|
||||
Date: 2026-02-05 Time: 17:57:56
|
||||
|
||||
--- Photometric ---
|
||||
Phi (lm): 182.62
|
||||
eta (lm/W): 0.00
|
||||
CCT (K): 4937
|
||||
Duv: 0.00214
|
||||
|
||||
--- Chromaticity ---
|
||||
x: 0.3474 y: 0.3577
|
||||
u: 0.2106 v: 0.3253
|
||||
u': 0.2106 v': 0.4880
|
||||
|
||||
--- CRI ---
|
||||
Ra: 83.5 SDCM: 1.52
|
||||
R1=82, R2=90, R3=95, R4=82, R5=82, R6=84, R7=87, R8=67
|
||||
R9=13, R10=75, R11=81, R12=54, R13=84, R14=97, R15=76
|
||||
|
||||
--- CIE 1931 ---
|
||||
X: 259.640 Y: 267.378 Z: 220.377
|
||||
TLCI-2012: 71
|
||||
|
||||
--- Radiometric ---
|
||||
Phi_e (mW): 558.636
|
||||
Phi_eb (mW): 152.549
|
||||
Phi_ey (mW): 257.969
|
||||
Phi_er (mW): 144.279
|
||||
Phi_efr (mW): 5.738
|
||||
|
||||
--- Electrical ---
|
||||
Voltage (V): 0.00
|
||||
Current (A): 0.0000
|
||||
Power (W): 0.000
|
||||
Frequency (Hz): 0.00
|
||||
Power Factor: 0.0000
|
||||
|
||||
--- Sensor ---
|
||||
Peak Signal: 55124
|
||||
Dark Signal: 2518
|
||||
Compensate: 2834
|
||||
@@ -0,0 +1,113 @@
|
||||
Device: HPCS6500
|
||||
Date: 2026-02-05 Time: 17:57:56
|
||||
|
||||
--- Photometric ---
|
||||
Phi (lm): 182.62
|
||||
eta (lm/W): 0.00
|
||||
CCT (K): 4937
|
||||
Duv: 0.00214
|
||||
|
||||
--- Chromaticity ---
|
||||
x: 0.3474 y: 0.3577
|
||||
u: 0.2106 v: 0.3253
|
||||
u': 0.2106 v': 0.4880
|
||||
|
||||
--- CRI ---
|
||||
Ra: 83.5 SDCM: 1.52
|
||||
R1=82, R2=90, R3=95, R4=82, R5=82, R6=84, R7=87, R8=67
|
||||
R9=13, R10=75, R11=81, R12=54, R13=84, R14=97, R15=76
|
||||
|
||||
--- CIE 1931 ---
|
||||
X: 259.640 Y: 267.378 Z: 220.377
|
||||
TLCI-2012: 71
|
||||
|
||||
--- Radiometric ---
|
||||
Phi_e (mW): 558.636
|
||||
Phi_eb (mW): 152.549
|
||||
Phi_ey (mW): 257.969
|
||||
Phi_er (mW): 144.279
|
||||
Phi_efr (mW): 5.738
|
||||
|
||||
--- Electrical ---
|
||||
Voltage (V): 0.00
|
||||
Current (A): 0.0000
|
||||
Power (W): 0.000
|
||||
Frequency (Hz): 0.00
|
||||
Power Factor: 0.0000
|
||||
|
||||
--- Sensor ---
|
||||
Peak Signal: 55124
|
||||
Dark Signal: 2518
|
||||
Compensate: 2834
|
||||
|
||||
--- Spectrum (380-1050nm) ---
|
||||
380.0nm: 0.0128 []
|
||||
389.6nm: 0.0556 []
|
||||
399.2nm: 0.1567 [#]
|
||||
408.8nm: 0.3618 [##]
|
||||
418.4nm: 0.7370 [#####]
|
||||
428.0nm: 1.3826 [##########]
|
||||
437.6nm: 2.5671 [####################]
|
||||
447.2nm: 4.3754 [##################################]
|
||||
456.8nm: 5.0546 [#######################################]
|
||||
466.4nm: 3.6483 [############################]
|
||||
476.0nm: 2.4748 [###################]
|
||||
485.6nm: 1.9566 [###############]
|
||||
495.2nm: 1.4662 [###########]
|
||||
504.8nm: 1.1959 [#########]
|
||||
514.4nm: 1.2093 [#########]
|
||||
524.0nm: 1.3289 [##########]
|
||||
533.6nm: 1.5025 [###########]
|
||||
543.2nm: 1.7276 [#############]
|
||||
552.8nm: 1.9439 [###############]
|
||||
562.4nm: 2.1145 [################]
|
||||
572.0nm: 2.2373 [#################]
|
||||
581.6nm: 2.3242 [##################]
|
||||
591.2nm: 2.3852 [##################]
|
||||
600.8nm: 2.4435 [###################]
|
||||
610.4nm: 2.5010 [###################]
|
||||
620.0nm: 2.5665 [####################]
|
||||
629.6nm: 2.6256 [####################]
|
||||
639.2nm: 2.6859 [####################]
|
||||
648.8nm: 2.7312 [#####################]
|
||||
658.4nm: 2.7605 [#####################]
|
||||
668.0nm: 2.7875 [#####################]
|
||||
677.6nm: 2.8117 [#####################]
|
||||
687.2nm: 2.8452 [######################]
|
||||
696.8nm: 2.8615 [######################]
|
||||
706.4nm: 2.8705 [######################]
|
||||
716.0nm: 2.8641 [######################]
|
||||
725.6nm: 2.8432 [######################]
|
||||
735.2nm: 2.8032 [#####################]
|
||||
744.8nm: 2.7383 [#####################]
|
||||
754.4nm: 2.6451 [####################]
|
||||
764.0nm: 2.5241 [###################]
|
||||
773.6nm: 2.3959 [##################]
|
||||
783.2nm: 2.2367 [#################]
|
||||
792.8nm: 2.0679 [################]
|
||||
802.3nm: 1.8961 [##############]
|
||||
811.9nm: 1.7246 [#############]
|
||||
821.5nm: 1.5452 [############]
|
||||
831.1nm: 1.3706 [##########]
|
||||
840.7nm: 1.2060 [#########]
|
||||
850.3nm: 1.0473 [########]
|
||||
859.9nm: 0.9111 [#######]
|
||||
869.5nm: 0.7856 [######]
|
||||
879.1nm: 0.6734 [#####]
|
||||
888.7nm: 0.5791 [####]
|
||||
898.3nm: 0.4811 [###]
|
||||
907.9nm: 0.4019 [###]
|
||||
917.5nm: 0.3368 [##]
|
||||
927.1nm: 0.2715 [##]
|
||||
936.7nm: 0.2226 [#]
|
||||
946.3nm: 0.1786 [#]
|
||||
955.9nm: 0.1472 [#]
|
||||
965.5nm: 0.1139 []
|
||||
975.1nm: 0.0910 []
|
||||
984.7nm: 0.0706 []
|
||||
994.3nm: 0.0585 []
|
||||
1003.9nm: 0.0451 []
|
||||
1013.5nm: 0.0379 []
|
||||
1023.1nm: 0.0298 []
|
||||
1032.7nm: 0.0207 []
|
||||
1042.3nm: 0.0151 []
|
||||
@@ -0,0 +1,90 @@
|
||||
|
||||
============================================================
|
||||
Reading 1 (t=0.01s)
|
||||
============================================================
|
||||
Device: HPCS6500
|
||||
Date: 2026-02-04 Time: 15:58:55
|
||||
|
||||
--- Photometric ---
|
||||
Phi (lm): 479.98
|
||||
eta (lm/W): 57.09
|
||||
CCT (K): 5653
|
||||
Duv: 0.00551
|
||||
|
||||
--- Chromaticity ---
|
||||
x: 0.3289 y: 0.3488
|
||||
u: 0.2015 v: 0.3206
|
||||
u': 0.2015 v': 0.4809
|
||||
|
||||
--- CRI ---
|
||||
Ra: 83.0 SDCM: 4.69
|
||||
R1=82, R2=94, R3=93, R4=77, R5=82, R6=89, R7=83, R8=64
|
||||
R9=5, R10=84, R11=76, R12=60, R13=87, R14=97, R15=76
|
||||
|
||||
--- CIE 1931 ---
|
||||
X: 662.526 Y: 702.748 Z: 649.286
|
||||
TLCI-2012: 68
|
||||
|
||||
--- Radiometric ---
|
||||
Phi_e (mW): 1492.631
|
||||
Phi_eb (mW): 470.398
|
||||
Phi_ey (mW): 680.493
|
||||
Phi_er (mW): 331.425
|
||||
Phi_efr (mW): 11.458
|
||||
|
||||
--- Electrical ---
|
||||
Voltage (V): 230.31
|
||||
Current (A): 0.0653
|
||||
Power (W): 8.407
|
||||
Frequency (Hz): 50.02
|
||||
Power Factor: 0.5591
|
||||
|
||||
--- Sensor ---
|
||||
Peak Signal: 53066
|
||||
Dark Signal: 2219
|
||||
Compensate: 2834
|
||||
|
||||
============================================================
|
||||
Reading 2 (t=2.13s)
|
||||
============================================================
|
||||
Device: HPCS6500
|
||||
Date: 2026-02-04 Time: 16:02:06
|
||||
|
||||
--- Photometric ---
|
||||
Phi (lm): 479.89
|
||||
eta (lm/W): 57.09
|
||||
CCT (K): 5655
|
||||
Duv: 0.00550
|
||||
|
||||
--- Chromaticity ---
|
||||
x: 0.3288 y: 0.3488
|
||||
u: 0.2015 v: 0.3206
|
||||
u': 0.2015 v': 0.4809
|
||||
|
||||
--- CRI ---
|
||||
Ra: 83.0 SDCM: 4.67
|
||||
R1=82, R2=94, R3=93, R4=77, R5=82, R6=89, R7=83, R8=64
|
||||
R9=5, R10=84, R11=76, R12=60, R13=86, R14=97, R15=76
|
||||
|
||||
--- CIE 1931 ---
|
||||
X: 662.427 Y: 702.620 Z: 649.490
|
||||
TLCI-2012: 68
|
||||
|
||||
--- Radiometric ---
|
||||
Phi_e (mW): 1492.469
|
||||
Phi_eb (mW): 470.073
|
||||
Phi_ey (mW): 679.697
|
||||
Phi_er (mW): 330.648
|
||||
Phi_efr (mW): 11.237
|
||||
|
||||
--- Electrical ---
|
||||
Voltage (V): 230.30
|
||||
Current (A): 0.0653
|
||||
Power (W): 8.406
|
||||
Frequency (Hz): 50.02
|
||||
Power Factor: 0.5592
|
||||
|
||||
--- Sensor ---
|
||||
Peak Signal: 53055
|
||||
Dark Signal: 2246
|
||||
Compensate: 2834
|
||||
@@ -0,0 +1,234 @@
|
||||
|
||||
============================================================
|
||||
Reading 1 (t=0.01s)
|
||||
============================================================
|
||||
Device: HPCS6500
|
||||
Date: 2026-02-04 Time: 15:58:55
|
||||
|
||||
--- Photometric ---
|
||||
Phi (lm): 479.98
|
||||
eta (lm/W): 57.09
|
||||
CCT (K): 5653
|
||||
Duv: 0.00551
|
||||
|
||||
--- Chromaticity ---
|
||||
x: 0.3289 y: 0.3488
|
||||
u: 0.2015 v: 0.3206
|
||||
u': 0.2015 v': 0.4809
|
||||
|
||||
--- CRI ---
|
||||
Ra: 83.0 SDCM: 4.69
|
||||
R1=82, R2=94, R3=93, R4=77, R5=82, R6=89, R7=83, R8=64
|
||||
R9=5, R10=84, R11=76, R12=60, R13=87, R14=97, R15=76
|
||||
|
||||
--- CIE 1931 ---
|
||||
X: 662.526 Y: 702.748 Z: 649.286
|
||||
TLCI-2012: 68
|
||||
|
||||
--- Radiometric ---
|
||||
Phi_e (mW): 1492.631
|
||||
Phi_eb (mW): 470.398
|
||||
Phi_ey (mW): 680.493
|
||||
Phi_er (mW): 331.425
|
||||
Phi_efr (mW): 11.458
|
||||
|
||||
--- Electrical ---
|
||||
Voltage (V): 230.31
|
||||
Current (A): 0.0653
|
||||
Power (W): 8.407
|
||||
Frequency (Hz): 50.02
|
||||
Power Factor: 0.5591
|
||||
|
||||
--- Sensor ---
|
||||
Peak Signal: 53066
|
||||
Dark Signal: 2219
|
||||
Compensate: 2834
|
||||
|
||||
--- Spectrum (380-1050nm) ---
|
||||
380.0nm: 0.0008 []
|
||||
389.6nm: 0.0890 []
|
||||
399.2nm: 0.3229 []
|
||||
408.8nm: 0.7770 [##]
|
||||
418.4nm: 1.6247 [####]
|
||||
428.0nm: 3.0114 [########]
|
||||
437.6nm: 5.1952 [##############]
|
||||
447.2nm: 8.5884 [########################]
|
||||
456.8nm: 12.6235 [####################################]
|
||||
466.4nm: 13.6578 [#######################################]
|
||||
476.0nm: 10.8532 [###############################]
|
||||
485.6nm: 8.3235 [########################]
|
||||
495.2nm: 6.7865 [###################]
|
||||
504.8nm: 5.5139 [###############]
|
||||
514.4nm: 4.7091 [#############]
|
||||
524.0nm: 4.4881 [############]
|
||||
533.6nm: 4.6034 [#############]
|
||||
543.2nm: 4.8833 [##############]
|
||||
552.8nm: 5.2877 [###############]
|
||||
562.4nm: 5.6993 [################]
|
||||
572.0nm: 6.0321 [#################]
|
||||
581.6nm: 6.2673 [##################]
|
||||
591.2nm: 6.4150 [##################]
|
||||
600.8nm: 6.5413 [##################]
|
||||
610.4nm: 6.6433 [###################]
|
||||
620.0nm: 6.7819 [###################]
|
||||
629.6nm: 6.9311 [####################]
|
||||
639.2nm: 7.0607 [####################]
|
||||
648.8nm: 7.1652 [####################]
|
||||
658.4nm: 7.2412 [####################]
|
||||
668.0nm: 7.3020 [#####################]
|
||||
677.6nm: 7.3772 [#####################]
|
||||
687.2nm: 7.5026 [#####################]
|
||||
696.8nm: 7.5342 [#####################]
|
||||
706.4nm: 7.4841 [#####################]
|
||||
716.0nm: 7.3912 [#####################]
|
||||
725.6nm: 7.2247 [####################]
|
||||
735.2nm: 7.0265 [####################]
|
||||
744.8nm: 6.7293 [###################]
|
||||
754.4nm: 6.3794 [##################]
|
||||
764.0nm: 5.9717 [#################]
|
||||
773.6nm: 5.5819 [################]
|
||||
783.2nm: 5.1382 [##############]
|
||||
792.8nm: 4.7075 [#############]
|
||||
802.3nm: 4.2856 [############]
|
||||
811.9nm: 3.8809 [###########]
|
||||
821.5nm: 3.4503 [#########]
|
||||
831.1nm: 3.0323 [########]
|
||||
840.7nm: 2.6561 [#######]
|
||||
850.3nm: 2.2916 [######]
|
||||
859.9nm: 1.9863 [#####]
|
||||
869.5nm: 1.7127 [####]
|
||||
879.1nm: 1.4511 [####]
|
||||
888.7nm: 1.2506 [###]
|
||||
898.3nm: 1.0459 [###]
|
||||
907.9nm: 0.8602 [##]
|
||||
917.5nm: 0.7136 [##]
|
||||
927.1nm: 0.5768 [#]
|
||||
936.7nm: 0.4717 [#]
|
||||
946.3nm: 0.3844 [#]
|
||||
955.9nm: 0.3112 []
|
||||
965.5nm: 0.2369 []
|
||||
975.1nm: 0.1834 []
|
||||
984.7nm: 0.1431 []
|
||||
994.3nm: 0.1127 []
|
||||
1003.9nm: 0.0847 []
|
||||
1013.5nm: 0.0629 []
|
||||
1023.1nm: 0.0458 []
|
||||
1032.7nm: 0.0273 []
|
||||
1042.3nm: 0.0104 []
|
||||
|
||||
============================================================
|
||||
Reading 2 (t=2.13s)
|
||||
============================================================
|
||||
Device: HPCS6500
|
||||
Date: 2026-02-04 Time: 16:02:06
|
||||
|
||||
--- Photometric ---
|
||||
Phi (lm): 479.89
|
||||
eta (lm/W): 57.09
|
||||
CCT (K): 5655
|
||||
Duv: 0.00550
|
||||
|
||||
--- Chromaticity ---
|
||||
x: 0.3288 y: 0.3488
|
||||
u: 0.2015 v: 0.3206
|
||||
u': 0.2015 v': 0.4809
|
||||
|
||||
--- CRI ---
|
||||
Ra: 83.0 SDCM: 4.67
|
||||
R1=82, R2=94, R3=93, R4=77, R5=82, R6=89, R7=83, R8=64
|
||||
R9=5, R10=84, R11=76, R12=60, R13=86, R14=97, R15=76
|
||||
|
||||
--- CIE 1931 ---
|
||||
X: 662.427 Y: 702.620 Z: 649.490
|
||||
TLCI-2012: 68
|
||||
|
||||
--- Radiometric ---
|
||||
Phi_e (mW): 1492.469
|
||||
Phi_eb (mW): 470.073
|
||||
Phi_ey (mW): 679.697
|
||||
Phi_er (mW): 330.648
|
||||
Phi_efr (mW): 11.237
|
||||
|
||||
--- Electrical ---
|
||||
Voltage (V): 230.30
|
||||
Current (A): 0.0653
|
||||
Power (W): 8.406
|
||||
Frequency (Hz): 50.02
|
||||
Power Factor: 0.5592
|
||||
|
||||
--- Sensor ---
|
||||
Peak Signal: 53055
|
||||
Dark Signal: 2246
|
||||
Compensate: 2834
|
||||
|
||||
--- Spectrum (380-1050nm) ---
|
||||
380.0nm: 0.0003 []
|
||||
389.6nm: 0.0894 []
|
||||
399.2nm: 0.3190 []
|
||||
408.8nm: 0.7735 [##]
|
||||
418.4nm: 1.6266 [####]
|
||||
428.0nm: 3.0219 [########]
|
||||
437.6nm: 5.2066 [###############]
|
||||
447.2nm: 8.5984 [########################]
|
||||
456.8nm: 12.6300 [####################################]
|
||||
466.4nm: 13.6373 [#######################################]
|
||||
476.0nm: 10.8302 [###############################]
|
||||
485.6nm: 8.3115 [########################]
|
||||
495.2nm: 6.7760 [###################]
|
||||
504.8nm: 5.5051 [###############]
|
||||
514.4nm: 4.7003 [#############]
|
||||
524.0nm: 4.4846 [############]
|
||||
533.6nm: 4.5981 [#############]
|
||||
543.2nm: 4.8781 [##############]
|
||||
552.8nm: 5.2836 [###############]
|
||||
562.4nm: 5.6906 [################]
|
||||
572.0nm: 6.0287 [#################]
|
||||
581.6nm: 6.2648 [##################]
|
||||
591.2nm: 6.4112 [##################]
|
||||
600.8nm: 6.5259 [##################]
|
||||
610.4nm: 6.6311 [###################]
|
||||
620.0nm: 6.7794 [###################]
|
||||
629.6nm: 6.9210 [###################]
|
||||
639.2nm: 7.0479 [####################]
|
||||
648.8nm: 7.1559 [####################]
|
||||
658.4nm: 7.2319 [####################]
|
||||
668.0nm: 7.2973 [#####################]
|
||||
677.6nm: 7.3794 [#####################]
|
||||
687.2nm: 7.4932 [#####################]
|
||||
696.8nm: 7.5221 [#####################]
|
||||
706.4nm: 7.4767 [#####################]
|
||||
716.0nm: 7.3786 [#####################]
|
||||
725.6nm: 7.2110 [####################]
|
||||
735.2nm: 7.0181 [####################]
|
||||
744.8nm: 6.7234 [###################]
|
||||
754.4nm: 6.3685 [##################]
|
||||
764.0nm: 5.9592 [#################]
|
||||
773.6nm: 5.5700 [################]
|
||||
783.2nm: 5.1347 [##############]
|
||||
792.8nm: 4.6961 [#############]
|
||||
802.3nm: 4.2714 [############]
|
||||
811.9nm: 3.8696 [###########]
|
||||
821.5nm: 3.4402 [#########]
|
||||
831.1nm: 3.0220 [########]
|
||||
840.7nm: 2.6562 [#######]
|
||||
850.3nm: 2.2910 [######]
|
||||
859.9nm: 1.9745 [#####]
|
||||
869.5nm: 1.7021 [####]
|
||||
879.1nm: 1.4475 [####]
|
||||
888.7nm: 1.2388 [###]
|
||||
898.3nm: 1.0408 [###]
|
||||
907.9nm: 0.8597 [##]
|
||||
917.5nm: 0.7104 [##]
|
||||
927.1nm: 0.5750 [#]
|
||||
936.7nm: 0.4669 [#]
|
||||
946.3nm: 0.3804 [#]
|
||||
955.9nm: 0.3071 []
|
||||
965.5nm: 0.2300 []
|
||||
975.1nm: 0.1785 []
|
||||
984.7nm: 0.1403 []
|
||||
994.3nm: 0.1103 []
|
||||
1003.9nm: 0.0812 []
|
||||
1013.5nm: 0.0610 []
|
||||
1023.1nm: 0.0417 []
|
||||
1032.7nm: 0.0264 []
|
||||
1042.3nm: 0.0066 []
|
||||
@@ -0,0 +1,2 @@
|
||||
#1 480.0 lm 5653 K
|
||||
#2 479.9 lm 5655 K
|
||||
@@ -0,0 +1,47 @@
|
||||
|
||||
============================================================
|
||||
Reading 1 (t=0.01s)
|
||||
============================================================
|
||||
Device: HPCS6500
|
||||
Date: 2026-02-04 Time: 17:07:33
|
||||
|
||||
--- Photometric ---
|
||||
Phi (lm): 0.00
|
||||
eta (lm/W): 0.00
|
||||
CCT (K): 0
|
||||
Duv: 0.00000
|
||||
|
||||
--- Chromaticity ---
|
||||
x: 0.0000 y: 0.0000
|
||||
u: 0.0000 v: 0.0000
|
||||
u': 0.0000 v': 0.0000
|
||||
|
||||
--- CRI ---
|
||||
Ra: 0.0 SDCM: 0.00
|
||||
R1=0, R2=0, R3=0, R4=0, R5=0, R6=0, R7=0, R8=0
|
||||
R9=0, R10=0, R11=0, R12=0, R13=0, R14=0, R15=0
|
||||
|
||||
--- CIE 1931 ---
|
||||
X: 0.000 Y: 0.000 Z: 0.000
|
||||
TLCI-2012: 0
|
||||
|
||||
--- Radiometric ---
|
||||
Phi_e (mW): 0.000
|
||||
Phi_eb (mW): 0.000
|
||||
Phi_ey (mW): 0.000
|
||||
Phi_er (mW): 0.000
|
||||
Phi_efr (mW): 0.000
|
||||
|
||||
--- Electrical ---
|
||||
Voltage (V): 0.00
|
||||
Current (A): 0.0000
|
||||
Power (W): 0.000
|
||||
Frequency (Hz): 60.04
|
||||
Power Factor: 0.0000
|
||||
UThd (%): 0.8692
|
||||
AThd (%): 116.7091
|
||||
|
||||
--- Sensor ---
|
||||
Peak Signal: 3801
|
||||
Dark Signal: 3670
|
||||
Compensate: 2836
|
||||
@@ -0,0 +1,223 @@
|
||||
|
||||
============================================================
|
||||
Reading 1 (t=0.01s)
|
||||
============================================================
|
||||
Device: HPCS6500
|
||||
Date: 2026-02-04 Time: 17:07:33
|
||||
|
||||
--- Photometric ---
|
||||
Phi (lm): 0.00
|
||||
eta (lm/W): 0.00
|
||||
CCT (K): 0
|
||||
Duv: 0.00000
|
||||
|
||||
--- Chromaticity ---
|
||||
x: 0.0000 y: 0.0000
|
||||
u: 0.0000 v: 0.0000
|
||||
u': 0.0000 v': 0.0000
|
||||
|
||||
--- CRI ---
|
||||
Ra: 0.0 SDCM: 0.00
|
||||
R1=0, R2=0, R3=0, R4=0, R5=0, R6=0, R7=0, R8=0
|
||||
R9=0, R10=0, R11=0, R12=0, R13=0, R14=0, R15=0
|
||||
|
||||
--- CIE 1931 ---
|
||||
X: 0.000 Y: 0.000 Z: 0.000
|
||||
TLCI-2012: 0
|
||||
|
||||
--- Radiometric ---
|
||||
Phi_e (mW): 0.000
|
||||
Phi_eb (mW): 0.000
|
||||
Phi_ey (mW): 0.000
|
||||
Phi_er (mW): 0.000
|
||||
Phi_efr (mW): 0.000
|
||||
|
||||
--- Electrical ---
|
||||
Voltage (V): 0.00
|
||||
Current (A): 0.0000
|
||||
Power (W): 0.000
|
||||
Frequency (Hz): 60.04
|
||||
Power Factor: 0.0000
|
||||
UThd (%): 0.8692
|
||||
AThd (%): 116.7091
|
||||
|
||||
--- Voltage Harmonics (%) ---
|
||||
H 1: 100.0000
|
||||
H 2: 0.3466
|
||||
H 3: 0.5529
|
||||
H 4: 0.1935
|
||||
H 5: 0.1009
|
||||
H 6: 0.1319
|
||||
H 7: 0.1455
|
||||
H 8: 0.0709
|
||||
H 9: 0.3610
|
||||
H10: 0.0452
|
||||
H11: 0.2558
|
||||
H12: 0.0099
|
||||
H13: 0.1471
|
||||
H14: 0.0182
|
||||
H15: 0.0881
|
||||
H16: 0.0119
|
||||
H17: 0.0439
|
||||
H18: 0.0054
|
||||
H19: 0.0441
|
||||
H20: 0.0039
|
||||
H21: 0.0444
|
||||
H22: 0.0038
|
||||
H23: 0.0419
|
||||
H24: 0.0070
|
||||
H25: 0.0277
|
||||
H26: 0.0086
|
||||
H27: 0.0180
|
||||
H28: 0.0038
|
||||
H29: 0.0168
|
||||
H30: 0.0031
|
||||
H31: 0.0085
|
||||
H32: 0.0042
|
||||
H33: 0.0141
|
||||
H34: 0.0039
|
||||
H35: 0.0050
|
||||
H36: 0.0076
|
||||
H37: 0.0172
|
||||
H38: 0.0017
|
||||
H39: 0.0135
|
||||
H40: 0.0122
|
||||
H41: 0.0075
|
||||
H42: 0.0091
|
||||
H43: 0.0079
|
||||
H44: 0.0052
|
||||
H45: 0.0028
|
||||
H46: 0.0063
|
||||
H47: 0.0106
|
||||
H48: 0.0055
|
||||
H49: 0.0066
|
||||
H50: 0.0032
|
||||
|
||||
--- Current Harmonics (%) ---
|
||||
H 1: 100.0000
|
||||
H 2: 1.3069
|
||||
H 3: 79.6826
|
||||
H 4: 1.6782
|
||||
H 5: 62.0099
|
||||
H 6: 1.8375
|
||||
H 7: 43.0251
|
||||
H 8: 1.4561
|
||||
H 9: 27.1434
|
||||
H10: 0.8638
|
||||
H11: 16.3288
|
||||
H12: 0.8914
|
||||
H13: 10.5836
|
||||
H14: 0.8186
|
||||
H15: 7.8296
|
||||
H16: 0.6092
|
||||
H17: 7.1998
|
||||
H18: 0.5320
|
||||
H19: 6.8390
|
||||
H20: 0.7901
|
||||
H21: 5.9471
|
||||
H22: 0.8641
|
||||
H23: 5.4131
|
||||
H24: 0.7893
|
||||
H25: 5.4738
|
||||
H26: 0.6972
|
||||
H27: 5.4525
|
||||
H28: 0.6923
|
||||
H29: 4.8222
|
||||
H30: 0.7761
|
||||
H31: 4.3909
|
||||
H32: 0.7792
|
||||
H33: 4.3903
|
||||
H34: 0.7458
|
||||
H35: 4.3602
|
||||
H36: 0.7061
|
||||
H37: 3.9436
|
||||
H38: 0.6865
|
||||
H39: 3.6134
|
||||
H40: 0.6928
|
||||
H41: 3.4965
|
||||
H42: 0.7969
|
||||
H43: 3.1709
|
||||
H44: 0.7282
|
||||
H45: 2.8721
|
||||
H46: 0.6770
|
||||
H47: 2.6774
|
||||
H48: 0.6486
|
||||
H49: 2.6343
|
||||
H50: 0.6855
|
||||
|
||||
--- Sensor ---
|
||||
Peak Signal: 3801
|
||||
Dark Signal: 3670
|
||||
Compensate: 2836
|
||||
|
||||
--- Spectrum (380-1050nm) ---
|
||||
380.0nm: 0.0000 []
|
||||
389.6nm: 0.0000 []
|
||||
399.2nm: 0.0000 []
|
||||
408.8nm: 0.0000 []
|
||||
418.4nm: 0.0000 []
|
||||
428.0nm: 0.0000 []
|
||||
437.6nm: 0.0000 []
|
||||
447.2nm: 0.0000 []
|
||||
456.8nm: 0.0000 []
|
||||
466.4nm: 0.0000 []
|
||||
476.0nm: 0.0000 []
|
||||
485.6nm: 0.0000 []
|
||||
495.2nm: 0.0000 []
|
||||
504.8nm: 0.0000 []
|
||||
514.4nm: 0.0000 []
|
||||
524.0nm: 0.0000 []
|
||||
533.6nm: 0.0000 []
|
||||
543.2nm: 0.0000 []
|
||||
552.8nm: 0.0000 []
|
||||
562.4nm: 0.0000 []
|
||||
572.0nm: 0.0000 []
|
||||
581.6nm: 0.0000 []
|
||||
591.2nm: 0.0000 []
|
||||
600.8nm: 0.0000 []
|
||||
610.4nm: 0.0000 []
|
||||
620.0nm: 0.0000 []
|
||||
629.6nm: 0.0000 []
|
||||
639.2nm: 0.0000 []
|
||||
648.8nm: 0.0000 []
|
||||
658.4nm: 0.0000 []
|
||||
668.0nm: 0.0000 []
|
||||
677.6nm: 0.0000 []
|
||||
687.2nm: 0.0000 []
|
||||
696.8nm: 0.0000 []
|
||||
706.4nm: 0.0000 []
|
||||
716.0nm: 0.0000 []
|
||||
725.6nm: 0.0000 []
|
||||
735.2nm: 0.0000 []
|
||||
744.8nm: 0.0000 []
|
||||
754.4nm: 0.0000 []
|
||||
764.0nm: 0.0000 []
|
||||
773.6nm: 0.0000 []
|
||||
783.2nm: 0.0000 []
|
||||
792.8nm: 0.0000 []
|
||||
802.3nm: 0.0000 []
|
||||
811.9nm: 0.0000 []
|
||||
821.5nm: 0.0000 []
|
||||
831.1nm: 0.0000 []
|
||||
840.7nm: 0.0000 []
|
||||
850.3nm: 0.0000 []
|
||||
859.9nm: 0.0000 []
|
||||
869.5nm: 0.0000 []
|
||||
879.1nm: 0.0000 []
|
||||
888.7nm: 0.0000 []
|
||||
898.3nm: 0.0000 []
|
||||
907.9nm: 0.0000 []
|
||||
917.5nm: 0.0000 []
|
||||
927.1nm: 0.0000 []
|
||||
936.7nm: 0.0000 []
|
||||
946.3nm: 0.0000 []
|
||||
955.9nm: 0.0000 []
|
||||
965.5nm: 0.0000 []
|
||||
975.1nm: 0.0000 []
|
||||
984.7nm: 0.0000 []
|
||||
994.3nm: 0.0000 []
|
||||
1003.9nm: 0.0000 []
|
||||
1013.5nm: 0.0000 []
|
||||
1023.1nm: 0.0000 []
|
||||
1032.7nm: 0.0000 []
|
||||
1042.3nm: 0.0000 []
|
||||
@@ -0,0 +1 @@
|
||||
#1 0.0 lm 0 K
|
||||
@@ -0,0 +1 @@
|
||||
[{"bits":0,"repr":"0.0","g6":"0","f2":"0.00","f0":"0","f1":"0.0","round1":"0.0"},{"bits":9223372036854775808,"repr":"-0.0","g6":"-0","f2":"-0.00","f0":"-0","f1":"-0.0","round1":"-0.0"},{"bits":4607182418800017408,"repr":"1.0","g6":"1","f2":"1.00","f0":"1","f1":"1.0","round1":"1.0"},{"bits":13830554455654793216,"repr":"-1.0","g6":"-1","f2":"-1.00","f0":"-1","f1":"-1.0","round1":"-1.0"},{"bits":4591870180066957722,"repr":"0.1","g6":"0.1","f2":"0.10","f0":"0","f1":"0.1","round1":"0.1"},{"bits":4602678819172646912,"repr":"0.5","g6":"0.5","f2":"0.50","f0":"0","f1":"0.5","round1":"0.5"},{"bits":4609434218613702656,"repr":"1.5","g6":"1.5","f2":"1.50","f0":"2","f1":"1.5","round1":"1.5"},{"bits":4612811918334230528,"repr":"2.5","g6":"2.5","f2":"2.50","f0":"2","f1":"2.5","round1":"2.5"},{"bits":4593671619917905920,"repr":"0.125","g6":"0.125","f2":"0.12","f0":"0","f1":"0.1","round1":"0.1"},{"bits":4600427019358961664,"repr":"0.375","g6":"0.375","f2":"0.38","f0":"0","f1":"0.4","round1":"0.4"},{"bits":4532020583610935537,"repr":"1e-05","g6":"1e-05","f2":"0.00","f0":"0","f1":"0.0","round1":"0.0"},{"bits":4547007122018943789,"repr":"0.0001","g6":"0.0001","f2":"0.00","f0":"0","f1":"0.0","round1":"0.0"},{"bits":4547007085125455641,"repr":"9.99995e-05","g6":"9.99995e-05","f2":"0.00","f0":"0","f1":"0.0","round1":"0.0"},{"bits":4683220279290232832,"repr":"123456.5","g6":"123456","f2":"123456.50","f0":"123456","f1":"123456.5","round1":"123456.5"},{"bits":4696837142389719040,"repr":"999999.5","g6":"1e+06","f2":"999999.50","f0":"1000000","f1":"999999.5","round1":"999999.5"},{"bits":4831355200913801216,"repr":"1000000000000000.0","g6":"1e+15","f2":"1000000000000000.00","f0":"1000000000000000","f1":"1000000000000000.0","round1":"1000000000000000.0"},{"bits":4846369599423283200,"repr":"1e+16","g6":"1e+16","f2":"10000000000000000.00","f0":"10000000000000000","f1":"10000000000000000.0","round1":"1e+16"},{"bits":4847542438873900484,"repr":"1.2345678901234568e+16","g6":"1.23457e+16","f2":"12345678901234568.00","f0":"12345678901234568","f1":"12345678901234568.0","round1":"1.2345678901234568e+16"},{"bits":4936209963552724370,"repr":"1e+22","g6":"1e+22","f2":"10000000000000000000000.00","f0":"10000000000000000000000","f1":"10000000000000000000000.0","round1":"1e+22"},{"bits":4502148214488346440,"repr":"1e-07","g6":"1e-07","f2":"0.00","f0":"0","f1":"0.0","round1":"0.0"},{"bits":1,"repr":"5e-324","g6":"4.94066e-324","f2":"0.00","f0":"0","f1":"0.0","round1":"0.0"},{"bits":9218868437227405311,"repr":"1.7976931348623157e+308","g6":"1.79769e+308","f2":null,"f0":null,"f1":null,"round1":"1.7976931348623157e+308"},{"bits":4636737291354636288,"repr":"100.0","g6":"100","f2":"100.00","f0":"100","f1":"100.0","round1":"100.0"},{"bits":4645392646888488960,"repr":"380.0","g6":"380","f2":"380.00","f0":"380","f1":"380.0","round1":"380.0"},{"bits":9221120237041090560,"repr":"nan","g6":"nan","f2":"nan","f0":"nan","f1":"nan","round1":null},{"bits":9218868437227405312,"repr":"inf","g6":"inf","f2":"inf","f0":"inf","f1":"inf","round1":null},{"bits":18442240474082181120,"repr":"-inf","g6":"-inf","f2":"-inf","f0":"-inf","f1":"-inf","round1":null},{"bits":4548686775598520385,"repr":"0.000123456789","g6":"0.000123457","f2":"0.00","f0":"0","f1":"0.0","round1":"0.0"},{"bits":4712889646492470477,"repr":"12345678.9","g6":"1.23457e+07","f2":"12345678.90","f0":"12345679","f1":"12345678.9","round1":"12345678.9"},{"bits":4607182409792818153,"repr":"0.999999","g6":"0.999999","f2":"1.00","f0":"1","f1":"1.0","round1":"1.0"},{"bits":4607182414296417781,"repr":"0.9999995","g6":"1","f2":"1.00","f0":"1","f1":"1.0","round1":"1.0"},{"bits":4621819117307496543,"repr":"9.9999995","g6":"10","f2":"10.00","f0":"10","f1":"10.0","round1":"10.0"},{"bits":4681608357448201011,"repr":"99999.95","g6":"99999.9","f2":"99999.95","f0":"100000","f1":"99999.9","round1":"99999.9"},{"bits":4698053236609777664,"repr":"1234567.0","g6":"1.23457e+06","f2":"1234567.00","f0":"1234567","f1":"1234567.0","round1":"1234567.0"},{"bits":4599075939470750516,"repr":"0.30000000000000004","g6":"0.3","f2":"0.30","f0":"0","f1":"0.3","round1":"0.3"},{"bits":4645392646888488960,"repr":"380.0","g6":"380","f2":"380.00","f0":"380","f1":"380.0","round1":"380.0"},{"bits":4645426419853101449,"repr":"381.91977077363896","g6":"381.92","f2":"381.92","f0":"382","f1":"381.9","round1":"381.9"},{"bits":4645460192817713938,"repr":"383.8395415472779","g6":"383.84","f2":"383.84","f0":"384","f1":"383.8","round1":"383.8"},{"bits":4645493965782326428,"repr":"385.75931232091693","g6":"385.759","f2":"385.76","f0":"386","f1":"385.8","round1":"385.8"},{"bits":4645527738746938917,"repr":"387.6790830945559","g6":"387.679","f2":"387.68","f0":"388","f1":"387.7","round1":"387.7"},{"bits":4645561511711551406,"repr":"389.59885386819485","g6":"389.599","f2":"389.60","f0":"390","f1":"389.6","round1":"389.6"},{"bits":4645595284676163895,"repr":"391.5186246418338","g6":"391.519","f2":"391.52","f0":"392","f1":"391.5","round1":"391.5"},{"bits":4645629057640Line truncated
|
||||
@@ -0,0 +1,2 @@
|
||||
#1 483.7 lm 5635 K
|
||||
483.7 lm 5635 K
|
||||
@@ -0,0 +1,2 @@
|
||||
#2 479.6 lm 5661 K
|
||||
479.6 lm 5661 K
|
||||
@@ -0,0 +1,2 @@
|
||||
#3 0.0 lm 0 K
|
||||
0.0 lm 0 K
|
||||
@@ -0,0 +1,2 @@
|
||||
#4 479.8 lm 5656 K
|
||||
479.8 lm 5656 K
|
||||
@@ -0,0 +1,2 @@
|
||||
#5 479.6 lm 5655 K
|
||||
479.6 lm 5655 K
|
||||
@@ -0,0 +1,2 @@
|
||||
#6 479.6 lm 5654 K
|
||||
479.6 lm 5654 K
|
||||
@@ -0,0 +1,2 @@
|
||||
#7 182.6 lm 4937 K
|
||||
182.6 lm 4937 K
|
||||
File diff suppressed because it is too large.
Load diff
+143
@@ -0,0 +1,143 @@
|
||||
//! The driver against the simulated instrument: command bytes, measurement
|
||||
//! sequences, supply handling, timeouts and cancellation.
|
||||
|
||||
use hpcs6500::protocol::{self, cmd};
|
||||
use hpcs6500::sim::Simulator;
|
||||
use hpcs6500::{Error, Hpcs6500, Mode};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn dev() -> Hpcs6500<Simulator> {
|
||||
Hpcs6500::with_transport(Simulator::sample(), "sim")
|
||||
}
|
||||
|
||||
fn cmds(d: &Hpcs6500<Simulator>) -> Vec<u8> {
|
||||
d.transport().log.iter().map(|f| f[1]).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_and_supply_commands() {
|
||||
let mut d = dev();
|
||||
assert_eq!(d.identify().unwrap(), "HPCS6500");
|
||||
d.set_mode(Mode::Dc).unwrap();
|
||||
d.set_dc_voltage(12.0).unwrap();
|
||||
d.set_dc_current(1.5).unwrap();
|
||||
d.set_ac_voltage(230.0).unwrap();
|
||||
d.set_ac_frequency(60.0).unwrap();
|
||||
d.set_integration_time(500.0).unwrap();
|
||||
let s = d.read_psu_settings().unwrap();
|
||||
assert_eq!(
|
||||
(
|
||||
s.mode,
|
||||
s.dc_voltage,
|
||||
s.dc_current,
|
||||
s.ac_voltage,
|
||||
s.ac_frequency
|
||||
),
|
||||
(Mode::Dc, 12.0, 1.5, 230.0, 60.0)
|
||||
);
|
||||
let log = &d.transport().log;
|
||||
assert_eq!(log[1], vec![0x8C, 0x7A, 0x01]);
|
||||
assert_eq!(log[2], protocol::frame_f32(cmd::SET_DC, 1, 12.0));
|
||||
assert_eq!(log[3], protocol::frame_f32(cmd::SET_DC, 0, 1.5));
|
||||
assert_eq!(log[6], vec![0x8C, 0x01, 0x20, 0xA1, 0x07, 0x00]); // 500 000 us
|
||||
d.psu_on().unwrap();
|
||||
assert!(d.transport().output_on);
|
||||
d.psu_off().unwrap();
|
||||
assert!(!d.transport().output_on);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_reading_follows_the_vendor_sequence() {
|
||||
let mut d = dev();
|
||||
d.send_test_config(false).unwrap();
|
||||
let r = d.take_single_reading().unwrap();
|
||||
assert_eq!(r.device, "HPCS6500");
|
||||
assert!((r.flux_lm() - 483.7375).abs() < 1e-3);
|
||||
assert!(r.electrical.is_some());
|
||||
// 2B 2B, trigger (0E 02), polls, 13, 77, reset
|
||||
let c = cmds(&d);
|
||||
assert_eq!(&c[..3], &[cmd::TEST_CONFIG, cmd::TEST_CONFIG, cmd::START]);
|
||||
assert_eq!(d.transport().log[2], vec![0x8C, 0x0E, 0x02]);
|
||||
assert!(c[3..c.len() - 3].iter().all(|&x| x == cmd::POLL_STATE));
|
||||
assert_eq!(
|
||||
&c[c.len() - 3..],
|
||||
&[cmd::READ_MEASUREMENT, cmd::READ_ELECTRICAL, cmd::RESET]
|
||||
);
|
||||
assert!(!d.transport().output_on, "the supply is not touched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuous_reading_switches_the_supply_around_it() {
|
||||
let mut d = dev();
|
||||
let r = d.take_reading(true).unwrap();
|
||||
assert_eq!(r.spectrum.len(), 350);
|
||||
let c = cmds(&d);
|
||||
assert_eq!(c[0], cmd::PSU_OUTPUT);
|
||||
assert_eq!(d.transport().log[0], vec![0x8C, 0x72, 0x00]);
|
||||
assert_eq!(d.transport().log[1], vec![0x8C, 0x0E, 0x01]);
|
||||
assert_eq!(
|
||||
&c[c.len() - 4..],
|
||||
&[
|
||||
cmd::READ_MEASUREMENT,
|
||||
cmd::READ_ELECTRICAL,
|
||||
cmd::START,
|
||||
cmd::PSU_OUTPUT
|
||||
]
|
||||
);
|
||||
assert_eq!(d.transport().log.last().unwrap(), &vec![0x8C, 0x72, 0x01]);
|
||||
assert!(!d.transport().output_on);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silent_instrument_times_out_and_the_supply_still_goes_off() {
|
||||
let mut d = dev();
|
||||
d.transport_mut().mute = vec![cmd::POLL_STATE];
|
||||
let mut sim = Simulator::sample();
|
||||
sim.mute = vec![cmd::IDENTIFY];
|
||||
let mut quiet = Hpcs6500::with_transport(sim, "sim");
|
||||
let t = Instant::now();
|
||||
assert!(matches!(
|
||||
quiet.identify(),
|
||||
Err(Error::BadResponse { cmd: 0, .. })
|
||||
));
|
||||
assert!(t.elapsed() >= Duration::from_secs(2) && t.elapsed() < Duration::from_secs(4));
|
||||
|
||||
// a reading whose data never arrives: cancel it instead of waiting 30 s
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
d.set_cancel_flag(stop.clone());
|
||||
let s2 = stop.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
s2.store(true, Ordering::SeqCst);
|
||||
});
|
||||
let t = Instant::now();
|
||||
let r = d.take_reading(true);
|
||||
assert!(matches!(r, Err(Error::Cancelled)), "{r:?}");
|
||||
assert!(t.elapsed() < Duration::from_secs(8));
|
||||
assert!(
|
||||
!d.transport().output_on,
|
||||
"supply off after the failed reading"
|
||||
);
|
||||
assert_eq!(d.transport().log.last().unwrap(), &vec![0x8C, 0x72, 0x01]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_ack_is_an_error() {
|
||||
let mut sim = Simulator::sample();
|
||||
sim.mute = vec![cmd::PSU_OUTPUT];
|
||||
let mut d = Hpcs6500::with_transport(sim, "sim");
|
||||
assert!(matches!(d.psu_on(), Err(Error::NoAck { cmd: 0x72, .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passive_read_and_replay_cycle_through_readings() {
|
||||
let mut sim = Simulator::sample();
|
||||
sim.polls_to_ready = 0;
|
||||
let mut d = Hpcs6500::with_transport(sim, "sim");
|
||||
let a = d.read_current().unwrap();
|
||||
let b = d.take_single_reading().unwrap();
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(d.transport().served, 1);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
"""
|
||||
ANSI/IES TM-30-18 computation from an HPCS 6500 spectrum.
|
||||
|
||||
Wraps colour-science to derive the fidelity index Rf, gamut index Rg, and the
|
||||
per-hue-bin data needed to draw a TM-30 color vector graphic (CVG): the test
|
||||
and reference chromaticity averages in CAM02-UCS a'b' (raw and normalized so
|
||||
the reference gamut is the unit circle), plus per-bin chroma shift, hue shift,
|
||||
and local fidelity.
|
||||
|
||||
The instrument spectrum is relative; TM-30 is scale-invariant, so no absolute
|
||||
calibration is required. Only the 380-780 nm range is used.
|
||||
"""
|
||||
|
||||
import warnings
|
||||
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
# colour warns at import time about optional scipy/matplotlib features;
|
||||
# the TM-30 path used here needs neither.
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
import colour
|
||||
from colour.quality import colour_fidelity_index_ANSIIESTM3018
|
||||
except ImportError as e: # pragma: no cover
|
||||
raise ImportError(
|
||||
"TM-30 computation requires colour-science; run `uv sync`"
|
||||
) from e
|
||||
|
||||
VISIBLE_MIN_NM = 380
|
||||
VISIBLE_MAX_NM = 780
|
||||
N_BINS = 16
|
||||
|
||||
|
||||
def compute_tm30(wavelengths_nm, values):
|
||||
"""Compute TM-30-18 quantities from a measured spectrum.
|
||||
|
||||
Args:
|
||||
wavelengths_nm: sequence of wavelengths in nm (any step; 380-1050
|
||||
from the HPCS 6500 is fine, the IR tail is discarded).
|
||||
values: spectral power at each wavelength (relative units).
|
||||
|
||||
Returns dict:
|
||||
Rf, Rg: floats
|
||||
CCT_K, Duv: floats (as derived by the TM-30 reference selection)
|
||||
bins: list of 16 dicts, hue bin 1..16, each with
|
||||
ref_a, ref_b, test_a, test_b raw CAM02-UCS bin averages
|
||||
ref_a_norm ... test_b_norm normalized (reference = unit circle)
|
||||
Rcs_pct chroma shift, percent
|
||||
Rhs hue shift (rad, CAM02-UCS)
|
||||
Rf_h local fidelity for the bin
|
||||
"""
|
||||
wl = np.asarray(wavelengths_nm, dtype=float)
|
||||
vals = np.asarray(values, dtype=float)
|
||||
mask = (wl >= VISIBLE_MIN_NM) & (wl <= VISIBLE_MAX_NM)
|
||||
if mask.sum() < 10:
|
||||
raise ValueError("spectrum does not cover the visible range")
|
||||
|
||||
# Resample onto a uniform 1 nm grid: the instrument's ~1.92 nm grid is
|
||||
# non-uniform in colour's eyes and triggers interpolator paths that need
|
||||
# scipy; a uniform grid keeps the pipeline on plain numpy.
|
||||
grid = np.arange(VISIBLE_MIN_NM, VISIBLE_MAX_NM + 1, 1, dtype=float)
|
||||
resampled = np.interp(grid, wl[mask], vals[mask])
|
||||
|
||||
sd = colour.SpectralDistribution(dict(zip(grid, resampled)))
|
||||
# colour emits informational "aligning shape" runtime warnings while it
|
||||
# adapts observers/illuminants to our 1 nm grid; not actionable.
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
spec = colour_fidelity_index_ANSIIESTM3018(sd, additional_data=True)
|
||||
|
||||
averages_test = np.asarray(spec.averages_test)
|
||||
averages_reference = np.asarray(spec.averages_reference)
|
||||
average_norms = np.asarray(spec.average_norms)
|
||||
R_cs = np.asarray(spec.R_cs)
|
||||
R_hs = np.asarray(spec.R_hs)
|
||||
|
||||
# Local fidelity per hue bin: mean of the per-CES R_f over the samples
|
||||
# assigned to the bin (spec.bins holds the bin index of each CES).
|
||||
sample_bins = np.asarray(spec.bins)
|
||||
R_s = np.asarray(spec.R_s)
|
||||
Rf_h = np.full(N_BINS, np.nan)
|
||||
for j in range(N_BINS):
|
||||
members = R_s[sample_bins == j]
|
||||
if members.size:
|
||||
Rf_h[j] = members.mean()
|
||||
|
||||
bins = []
|
||||
for j in range(N_BINS):
|
||||
norm = average_norms[j] if average_norms[j] else 1.0
|
||||
bins.append(
|
||||
{
|
||||
"bin": j + 1,
|
||||
"ref_a": float(averages_reference[j, 0]),
|
||||
"ref_b": float(averages_reference[j, 1]),
|
||||
"test_a": float(averages_test[j, 0]),
|
||||
"test_b": float(averages_test[j, 1]),
|
||||
"ref_a_norm": float(averages_reference[j, 0] / norm),
|
||||
"ref_b_norm": float(averages_reference[j, 1] / norm),
|
||||
"test_a_norm": float(averages_test[j, 0] / norm),
|
||||
"test_b_norm": float(averages_test[j, 1] / norm),
|
||||
"Rcs_pct": float(R_cs[j]),
|
||||
"Rhs": float(R_hs[j]),
|
||||
"Rf_h": float(Rf_h[j]),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"Rf": float(spec.R_f),
|
||||
"Rg": float(spec.R_g),
|
||||
"CCT_K": float(spec.CCT),
|
||||
"Duv": float(spec.D_uv),
|
||||
"bins": bins,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Sanity check against a known illuminant.
|
||||
sd = colour.SDS_ILLUMINANTS["FL2"]
|
||||
wl = sd.wavelengths
|
||||
result = compute_tm30(wl, sd.values)
|
||||
print(f"FL2: Rf={result['Rf']:.1f} Rg={result['Rg']:.1f} "
|
||||
f"CCT={result['CCT_K']:.0f} K Duv={result['Duv']:.4f}")
|
||||
for b in result["bins"][:4]:
|
||||
print(b)
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Golden fixtures for the Rust hpcs6500 driver, produced by the legacy Python driver.
|
||||
|
||||
Run it next to hpcs6500.py from tag python-final, with the USB captures in
|
||||
C:\dev\HPCS\captures (not in git):
|
||||
git show python-final:hpcs6500.py > /tmp/py/hpcs6500.py; cp tools/make_fixtures.py /tmp/py/
|
||||
python /tmp/py/make_fixtures.py <out> # then copy <out>/fixtures and <out>/golden to tests/
|
||||
pyfmt.json was trimmed to ~2000 cases afterwards.
|
||||
|
||||
Writes into <out>/:
|
||||
fixtures/rNN_meas.bin, rNN_elec.bin raw blocks from real USB captures
|
||||
golden/readings.json Python dict per fixture reading (floats as repr strings)
|
||||
golden/format_*.txt format_reading / format_quick output
|
||||
golden/csv_row.txt CSV row (without timestamp) as DictWriter writes it
|
||||
golden/pyfmt.json repr / .6g / .2f / .0f / .1f of many floats
|
||||
golden/parse_<capture>.txt `hpcs6500.py --parse` output, full and --quick --spectrum
|
||||
"""
|
||||
import contextlib
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
import hpcs6500 as h # noqa: E402
|
||||
|
||||
out = Path(sys.argv[1])
|
||||
captures = Path(r"C:\dev\HPCS\captures")
|
||||
(out / "fixtures").mkdir(parents=True, exist_ok=True)
|
||||
(out / "golden").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
pairs = [] # (name, meas, elec)
|
||||
for f in sorted(captures.glob("*.pcap")):
|
||||
if f.stat().st_size < 100:
|
||||
continue
|
||||
m = h.parse_pcap_messages(str(f))
|
||||
b = [x for x in m if x["dir"] == "RX" and len(x["data"]) == 3904 and x["data"][:8] == b"HPCS6500"]
|
||||
e = [x for x in m if x["dir"] == "RX" and len(x["data"]) == 1584]
|
||||
for i, blk in enumerate(b):
|
||||
el = e[i]["data"] if i < len(e) else None
|
||||
harm = el is not None and abs(struct.unpack_from("<f", el, 544)[0] - 100) < 1
|
||||
pairs.append((f.stem, i, blk["data"], el, harm))
|
||||
|
||||
print(len(pairs), "readings,", sum(p[4] for p in pairs), "with harmonics")
|
||||
# pick: first, one with harmonics, one with zero electrical, and a few spread out
|
||||
chosen = []
|
||||
seen = set()
|
||||
def pick(p):
|
||||
key = (p[0], p[1])
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
chosen.append(p)
|
||||
pick(pairs[0])
|
||||
for p in pairs:
|
||||
if p[4]:
|
||||
pick(p)
|
||||
break
|
||||
for p in pairs:
|
||||
if p[3] is not None and struct.unpack_from("<f", p[3], 8)[0] == 0:
|
||||
pick(p)
|
||||
break
|
||||
for k in range(0, len(pairs), max(1, len(pairs) // 4)):
|
||||
pick(pairs[k])
|
||||
pick(pairs[-1])
|
||||
|
||||
dev = h.HPCS6500.__new__(h.HPCS6500)
|
||||
|
||||
|
||||
def jsonable(v):
|
||||
if isinstance(v, float):
|
||||
return {"f": repr(v)}
|
||||
if isinstance(v, list):
|
||||
return [jsonable(x) for x in v]
|
||||
return v
|
||||
|
||||
|
||||
readings = []
|
||||
for n, (cap, i, meas, elec, harm) in enumerate(chosen, 1):
|
||||
name = f"r{n:02d}"
|
||||
(out / "fixtures" / f"{name}_meas.bin").write_bytes(meas)
|
||||
if elec is not None:
|
||||
(out / "fixtures" / f"{name}_elec.bin").write_bytes(elec)
|
||||
r = dev._parse_measurement(meas)
|
||||
if elec is not None:
|
||||
r.update(dev._parse_electrical(elec))
|
||||
readings.append({"name": name, "source": f"{cap}#{i}", "harmonics": harm,
|
||||
"keys": list(r.keys()), "values": {k: jsonable(v) for k, v in r.items()}})
|
||||
(out / "golden" / f"format_{name}.txt").write_text(h.format_reading(r), encoding="utf-8", newline="\n")
|
||||
(out / "golden" / f"format_{name}_spectrum.txt").write_text(h.format_reading(r, show_spectrum=True), encoding="utf-8", newline="\n")
|
||||
(out / "golden" / f"quick_{name}.txt").write_text(h.format_quick(r, n) + "\n" + h.format_quick(r), encoding="utf-8", newline="\n")
|
||||
buf = io.StringIO()
|
||||
fieldnames = ["timestamp"] + list(h.FIELDS.keys()) + list(h.ELECTRICAL_FIELDS.keys())
|
||||
w = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction="ignore")
|
||||
w.writeheader()
|
||||
row = {"timestamp": "T"}
|
||||
row.update({k: v for k, v in r.items() if isinstance(v, (int, float))})
|
||||
w.writerow(row)
|
||||
(out / "golden" / f"csv_{name}.txt").write_bytes(buf.getvalue().encode())
|
||||
(out / "golden" / "readings.json").write_text(json.dumps(readings, indent=1))
|
||||
print("chosen:", [(c[0], c[1], c[4]) for c in chosen])
|
||||
|
||||
# --parse output for two real captures
|
||||
for cap in ["usb_capture_20260204_123655_hub1", "usb_capture_20260204_134301_hub1"]:
|
||||
for flags, suffix in (([], ""), (["--quick"], "_quick"), (["--harmonics"], "_harmonics")):
|
||||
sys.argv = ["hpcs6500.py", *flags, "--parse", str(captures / f"{cap}.pcap")]
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
h.main()
|
||||
(out / "golden" / f"parse_{cap}{suffix}.txt").write_text(buf.getvalue(), encoding="utf-8", newline="\n")
|
||||
|
||||
# float formatting golden
|
||||
rng = random.Random(6500)
|
||||
vals = [0.0, -0.0, 1.0, -1.0, 0.1, 0.5, 1.5, 2.5, 0.125, 0.375, 1e-5, 1e-4, 9.99995e-5, 123456.5, 999999.5,
|
||||
1e15, 1e16, 1.2345678901234567e16, 1e22, 1e-7, 5e-324, 1.7976931348623157e308, 100.0, 380.0,
|
||||
float("nan"), float("inf"), -float("inf"), 0.000123456789, 12345678.9, 0.999999, 0.9999995,
|
||||
9.9999995, 99999.95, 1234567.0, 0.1 + 0.2]
|
||||
vals += [380 + i * h.SPECTRUM_STEP for i in range(350)]
|
||||
for meas_vals in readings:
|
||||
for k, v in meas_vals["values"].items():
|
||||
if isinstance(v, dict):
|
||||
vals.append(float(v["f"]))
|
||||
elif isinstance(v, list) and v and isinstance(v[0], dict):
|
||||
vals.extend(float(x["f"]) for x in v[:60])
|
||||
for _ in range(3000):
|
||||
e = rng.uniform(-12, 18)
|
||||
x = rng.choice([-1, 1]) * 10 ** e
|
||||
vals.append(x)
|
||||
vals.append(struct.unpack("<f", struct.pack("<f", x if abs(x) < 3e38 else 1.0))[0])
|
||||
for _ in range(500):
|
||||
vals.append(rng.randint(0, 10**7) / rng.choice([2, 4, 8, 10, 100, 1000, 16, 1024]))
|
||||
rows = []
|
||||
for v in vals:
|
||||
rows.append({"bits": struct.unpack("<Q", struct.pack("<d", v))[0], "repr": repr(v), "g6": format(v, ".6g"),
|
||||
"f2": format(v, ".2f") if abs(v) < 1e30 or math.isnan(v) or math.isinf(v) else None,
|
||||
"f0": format(v, ".0f") if abs(v) < 1e30 or math.isnan(v) or math.isinf(v) else None,
|
||||
"f1": format(v, ".1f") if abs(v) < 1e30 or math.isnan(v) or math.isinf(v) else None,
|
||||
"round1": repr(round(v, 1)) if not (math.isnan(v) or math.isinf(v)) else None})
|
||||
(out / "golden" / "pyfmt.json").write_text(json.dumps(rows))
|
||||
print(len(rows), "float cases")
|
||||
-341
@@ -1,341 +0,0 @@
|
||||
"""
|
||||
HPCS6500 USB Traffic Capture & Parser
|
||||
|
||||
1. Starts USBPcap to capture USB traffic from the HPCS6500 device
|
||||
2. You use the vendor software normally while this runs
|
||||
3. Press Ctrl+C to stop
|
||||
4. Parses the .pcap file and extracts serial data (USB bulk transfers)
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import struct
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
USBPCAP_CMD = r"C:\Program Files\USBPcap\USBPcapCMD.exe"
|
||||
CAPTURE_DIR = "captures"
|
||||
DEVICE_ADDRESS = 4 # From registry: Address = 4
|
||||
|
||||
|
||||
def find_usbpcap_devices():
|
||||
"""Return all USBPcap devices (USBPcap1 through USBPcap10)."""
|
||||
devices = []
|
||||
for i in range(1, 11):
|
||||
device = rf"\\.\USBPcap{i}"
|
||||
# Check if the device path exists by trying a quick capture to NUL
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[USBPCAP_CMD, "-d", device, "-o", "-", "-A"],
|
||||
capture_output=True, timeout=2,
|
||||
)
|
||||
devices.append(device)
|
||||
print(f" {device} - available")
|
||||
except subprocess.TimeoutExpired:
|
||||
devices.append(device)
|
||||
print(f" {device} - available (active)")
|
||||
except Exception:
|
||||
pass
|
||||
return devices
|
||||
|
||||
|
||||
def start_capture(device, output_file):
|
||||
"""Start USBPcap capture in background."""
|
||||
cmd = [
|
||||
USBPCAP_CMD,
|
||||
"-d", device,
|
||||
"-o", output_file,
|
||||
"-A", # Capture from all devices on this hub (safe, we filter later)
|
||||
"--devices", str(DEVICE_ADDRESS), # Only our device
|
||||
"-s", "65535", # Max snapshot length
|
||||
"-b", "1048576", # 1MB buffer
|
||||
]
|
||||
print(f"Starting capture: {' '.join(cmd)}")
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return proc
|
||||
|
||||
|
||||
def parse_pcap(filepath):
|
||||
"""Parse a pcap file with USBPcap headers and extract serial data."""
|
||||
with open(filepath, "rb") as f:
|
||||
data = f.read()
|
||||
|
||||
if len(data) < 24:
|
||||
print("Capture file too small or empty.")
|
||||
return
|
||||
|
||||
# Parse pcap global header
|
||||
magic = struct.unpack_from("<I", data, 0)[0]
|
||||
if magic == 0xA1B2C3D4:
|
||||
endian = "<"
|
||||
elif magic == 0xD4C3B2A1:
|
||||
endian = ">"
|
||||
else:
|
||||
print(f"Unknown pcap magic: {hex(magic)}")
|
||||
return
|
||||
|
||||
ver_major, ver_minor = struct.unpack_from(f"{endian}HH", data, 4)
|
||||
snaplen = struct.unpack_from(f"{endian}I", data, 16)[0]
|
||||
link_type = struct.unpack_from(f"{endian}I", data, 20)[0]
|
||||
print(f"PCAP: v{ver_major}.{ver_minor}, snaplen={snaplen}, link_type={link_type}")
|
||||
|
||||
# link_type 249 = USBPcap
|
||||
if link_type != 249:
|
||||
print(f"Warning: expected link_type 249 (USBPcap), got {link_type}")
|
||||
|
||||
offset = 24 # Start of first packet
|
||||
packets = []
|
||||
tx_data = bytearray() # Host -> Device (OUT)
|
||||
rx_data = bytearray() # Device -> Host (IN)
|
||||
|
||||
pkt_num = 0
|
||||
while offset + 16 <= len(data):
|
||||
# pcap packet header: ts_sec(4), ts_usec(4), incl_len(4), orig_len(4)
|
||||
ts_sec, ts_usec, incl_len, orig_len = struct.unpack_from(f"{endian}IIII", data, offset)
|
||||
offset += 16
|
||||
|
||||
if offset + incl_len > len(data):
|
||||
break
|
||||
|
||||
pkt_data = data[offset:offset + incl_len]
|
||||
offset += incl_len
|
||||
pkt_num += 1
|
||||
|
||||
# Parse USBPcap packet header (minimum 27 bytes)
|
||||
if len(pkt_data) < 27:
|
||||
continue
|
||||
|
||||
# USBPcap header:
|
||||
# headerLen (2 bytes LE)
|
||||
# irpId (8 bytes)
|
||||
# status (4 bytes)
|
||||
# function (2 bytes) - URB function
|
||||
# info (1 byte) - direction: bit 0: 0=OUT(host->dev), 1=IN(dev->host)
|
||||
# bus (2 bytes)
|
||||
# device (2 bytes)
|
||||
# endpoint (1 byte) - bit 7 = direction, bits 0-3 = endpoint number
|
||||
# transfer (1 byte) - 0=isoch, 1=interrupt, 2=control, 3=bulk
|
||||
# dataLength (4 bytes)
|
||||
|
||||
hdr_len = struct.unpack_from("<H", pkt_data, 0)[0]
|
||||
irp_id = struct.unpack_from("<Q", pkt_data, 2)[0]
|
||||
status = struct.unpack_from("<I", pkt_data, 10)[0]
|
||||
function = struct.unpack_from("<H", pkt_data, 14)[0]
|
||||
info = pkt_data[16]
|
||||
bus = struct.unpack_from("<H", pkt_data, 17)[0]
|
||||
device = struct.unpack_from("<H", pkt_data, 19)[0]
|
||||
endpoint = pkt_data[21]
|
||||
transfer = pkt_data[22]
|
||||
data_length = struct.unpack_from("<I", pkt_data, 23)[0]
|
||||
|
||||
# Payload starts after the USBPcap header
|
||||
payload = pkt_data[hdr_len:]
|
||||
|
||||
if len(payload) == 0:
|
||||
continue
|
||||
|
||||
# We care about bulk transfers (transfer type 3) with actual data
|
||||
transfer_types = {0: "ISOCH", 1: "INT", 2: "CTRL", 3: "BULK"}
|
||||
transfer_name = transfer_types.get(transfer, f"UNK({transfer})")
|
||||
|
||||
direction = "IN" if (info & 1) else "OUT"
|
||||
ep_num = endpoint & 0x0F
|
||||
ep_dir = "IN" if (endpoint & 0x80) else "OUT"
|
||||
|
||||
ts = ts_sec + ts_usec / 1_000_000
|
||||
|
||||
# Log all packets with data
|
||||
if transfer == 3 and len(payload) > 0: # Bulk transfers
|
||||
packets.append({
|
||||
"num": pkt_num,
|
||||
"ts": ts,
|
||||
"direction": direction,
|
||||
"ep": endpoint,
|
||||
"ep_dir": ep_dir,
|
||||
"transfer": transfer_name,
|
||||
"data": payload,
|
||||
"status": status,
|
||||
"device": device,
|
||||
})
|
||||
|
||||
if direction == "IN" or ep_dir == "IN":
|
||||
rx_data.extend(payload)
|
||||
else:
|
||||
tx_data.extend(payload)
|
||||
|
||||
print(f"\nParsed {pkt_num} total packets, {len(packets)} bulk transfers with data")
|
||||
print(f"TX (host -> device): {len(tx_data)} bytes")
|
||||
print(f"RX (device -> host): {len(rx_data)} bytes")
|
||||
|
||||
if not packets:
|
||||
print("\nNo bulk transfer data found.")
|
||||
return
|
||||
|
||||
# Print timeline
|
||||
print(f"\n{'='*80}")
|
||||
print("TRAFFIC TIMELINE")
|
||||
print(f"{'='*80}")
|
||||
|
||||
first_ts = packets[0]["ts"] if packets else 0
|
||||
for pkt in packets[:100]: # First 100 packets
|
||||
rel_ts = pkt["ts"] - first_ts
|
||||
d = pkt["direction"]
|
||||
payload = pkt["data"]
|
||||
|
||||
if d == "IN" or pkt["ep_dir"] == "IN":
|
||||
color = "\033[92m" # green for device -> host
|
||||
label = "DEV->PC"
|
||||
else:
|
||||
color = "\033[93m" # yellow for host -> device
|
||||
label = "PC->DEV"
|
||||
reset = "\033[0m"
|
||||
|
||||
hex_str = payload.hex(" ")
|
||||
if len(hex_str) > 90:
|
||||
hex_str = hex_str[:90] + f" ... (+{len(payload) - 30}B)"
|
||||
|
||||
ascii_str = "".join(chr(b) if 32 <= b < 127 else "." for b in payload[:40])
|
||||
|
||||
print(f"{color}[{rel_ts:8.4f}] {label} EP{pkt['ep']:02X} ({len(payload):4d}B): {hex_str}{reset}")
|
||||
# Show ASCII if useful
|
||||
printable = sum(1 for b in payload if 32 <= b < 127)
|
||||
if printable > len(payload) * 0.4:
|
||||
print(f" ASCII: {ascii_str!r}")
|
||||
|
||||
if len(packets) > 100:
|
||||
print(f" ... ({len(packets) - 100} more packets)")
|
||||
|
||||
# Save extracted serial data
|
||||
if tx_data:
|
||||
tx_file = filepath.replace(".pcap", "_TX.bin")
|
||||
with open(tx_file, "wb") as f:
|
||||
f.write(tx_data)
|
||||
print(f"\nTX data saved to: {tx_file}")
|
||||
|
||||
if rx_data:
|
||||
rx_file = filepath.replace(".pcap", "_RX.bin")
|
||||
with open(rx_file, "wb") as f:
|
||||
f.write(rx_data)
|
||||
print(f"\nRX data saved to: {rx_file}")
|
||||
|
||||
# Quick analysis of the data
|
||||
print(f"\n{'='*80}")
|
||||
print("QUICK ANALYSIS")
|
||||
print(f"{'='*80}")
|
||||
|
||||
for label, stream in [("TX (PC -> Device)", tx_data), ("RX (Device -> PC)", rx_data)]:
|
||||
if not stream:
|
||||
continue
|
||||
print(f"\n--- {label}: {len(stream)} bytes ---")
|
||||
printable = sum(1 for b in stream if 32 <= b < 127 or b in (0x0A, 0x0D))
|
||||
print(f" Printable ratio: {printable/len(stream):.0%}")
|
||||
|
||||
# Show first 200 bytes
|
||||
sample = bytes(stream[:200])
|
||||
print(f" HEX: {sample.hex(' ')}")
|
||||
print(f" ASCII: {''.join(chr(b) if 32 <= b < 127 else '.' for b in sample)}")
|
||||
|
||||
# Try to find repeating patterns
|
||||
if len(stream) > 10:
|
||||
# Look for common delimiters
|
||||
for delim_name, delim in [("\\r\\n", b"\r\n"), ("\\n", b"\n"), ("0xFF", b"\xff"), ("0xAA", b"\xaa")]:
|
||||
count = bytes(stream).count(delim)
|
||||
if count > 2:
|
||||
print(f" Delimiter {delim_name}: appears {count} times")
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(CAPTURE_DIR, exist_ok=True)
|
||||
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "--parse":
|
||||
# Parse mode: just parse an existing pcap file
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: usb_capture.py --parse <file.pcap>")
|
||||
sys.exit(1)
|
||||
parse_pcap(sys.argv[2])
|
||||
return
|
||||
|
||||
print("HPCS6500 USB Traffic Capture")
|
||||
print("=" * 60)
|
||||
print(f"Device address: {DEVICE_ADDRESS}")
|
||||
print()
|
||||
|
||||
# Find all USBPcap devices
|
||||
print("Looking for USBPcap filter devices...")
|
||||
devices = find_usbpcap_devices()
|
||||
if not devices:
|
||||
print("ERROR: No USBPcap devices found.")
|
||||
print(" - You may need to REBOOT after installing USBPcap")
|
||||
print(" - You must run this script as ADMINISTRATOR")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\nFound {len(devices)} USBPcap hub(s)")
|
||||
print()
|
||||
|
||||
# Try ALL hubs — capture from each one simultaneously
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
procs = []
|
||||
|
||||
for i, device in enumerate(devices):
|
||||
pcap_file = os.path.join(CAPTURE_DIR, f"usb_capture_{timestamp}_hub{i+1}.pcap")
|
||||
print(f"Starting capture on {device} -> {pcap_file}")
|
||||
proc = start_capture(device, pcap_file)
|
||||
procs.append((proc, device, pcap_file))
|
||||
|
||||
print()
|
||||
print("Capturing on ALL hubs simultaneously.")
|
||||
print(">>> Now use the vendor software — take a measurement")
|
||||
print(">>> Press Ctrl+C when done")
|
||||
print("-" * 60)
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Check if any process died
|
||||
all_dead = True
|
||||
for proc, device, _ in procs:
|
||||
if proc.poll() is None:
|
||||
all_dead = False
|
||||
if all_dead:
|
||||
print("\nAll capture processes have exited.")
|
||||
for proc, device, _ in procs:
|
||||
stderr = proc.stderr.read().decode(errors="replace").strip()
|
||||
print(f" {device}: exit code {proc.returncode}" +
|
||||
(f" - {stderr}" if stderr else ""))
|
||||
break
|
||||
time.sleep(0.5)
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nStopping captures...")
|
||||
for proc, _, _ in procs:
|
||||
proc.terminate()
|
||||
for proc, _, _ in procs:
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
# Check which captures got data
|
||||
print()
|
||||
best_file = None
|
||||
best_size = 0
|
||||
for proc, device, pcap_file in procs:
|
||||
if os.path.exists(pcap_file):
|
||||
size = os.path.getsize(pcap_file)
|
||||
print(f"{device} -> {pcap_file}: {size} bytes")
|
||||
if size > best_size:
|
||||
best_size = size
|
||||
best_file = pcap_file
|
||||
else:
|
||||
print(f"{device} -> no file created")
|
||||
|
||||
if best_file and best_size > 24:
|
||||
print(f"\nBest capture: {best_file} ({best_size} bytes)")
|
||||
print("\nParsing capture...\n")
|
||||
parse_pcap(best_file)
|
||||
else:
|
||||
print("\nNo USB data captured from any hub.")
|
||||
print("Make sure you're running as ADMINISTRATOR.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user