diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 8e251e8..cebd98d 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -19,6 +19,7 @@ jobs: profile: minimal toolchain: stable override: true + - run: sudo apt-get install libudev-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev - uses: actions-rs/cargo@v1 with: command: check @@ -50,7 +51,7 @@ jobs: profile: minimal toolchain: stable override: true - - run: sudo apt-get install libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev + - run: sudo apt-get install libudev-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev - uses: actions-rs/cargo@v1 with: command: test @@ -78,6 +79,7 @@ jobs: steps: - uses: actions/checkout@v4 - uses: actions-rust-lang/setup-rust-toolchain@v1 + - run: sudo apt-get install libudev-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev - run: cargo clippy -- -D warnings # trunk: diff --git a/CHANGELOG.md b/CHANGELOG.md index db8b152..9507758 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,13 @@ # CHANGELOG -## 0.4.7 +## 0.5.0 -- WIP +- add support for Victor (RuoShui) 86B/C/D/E handheld DMMs (read only) + +This release adds a whole new device class to Rusty Meter. +These Victor DMMs can be read out, but due to their nature +with a twist knob for the modes they can only be read out +and not remote controlled. ## 0.4.6 diff --git a/Cargo.lock b/Cargo.lock index a092453..37ceda6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1977,6 +1977,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "hidapi" +version = "2.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c78dadfc12f865bc3fcac3897e64533b930737ceb9ef245c8277de98d0b010e9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "pkg-config", + "windows-sys 0.61.2", +] + [[package]] name = "home" version = "0.5.9" @@ -4101,7 +4114,7 @@ checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" [[package]] name = "rusty_meter" -version = "0.4.6" +version = "0.5.0" dependencies = [ "arboard", "chrono", @@ -4116,6 +4129,7 @@ dependencies = [ "env_logger", "epaint", "glob", + "hidapi", "image", "indexmap", "log", @@ -4127,7 +4141,6 @@ dependencies = [ "rfd", "serde", "serde_json", - "subprocess", "tempfile", "tokio", "urlencoding", @@ -4462,16 +4475,6 @@ dependencies = [ "float-cmp", ] -[[package]] -name = "subprocess" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3713691ac66e56ba6bafb8a62158859653b3127ae5cacae98d437bbb0c9ccb06" -dependencies = [ - "libc", - "winapi", -] - [[package]] name = "subtle" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 746234d..23bbb80 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rusty_meter" -version = "0.4.6" +version = "0.5.0" authors = ["Markus Krause "] edition = "2024" rust-version = "1.92" # when updating here, also update release.yml and rust-toolchain file @@ -25,7 +25,6 @@ egui_plot = "0.35.0" log = "0.4.30" reqwest = { version = "0.13.4", features = ["blocking"] } urlencoding = "2.1.3" -subprocess = "1.1.0" serde_json = "1.0.150" regex = "1.12.3" indexmap = "2.14.0" @@ -48,11 +47,16 @@ rfd = "0.17.2" # For file dialogs # native: [target.'cfg(not(target_arch = "wasm32"))'.dependencies] env_logger = "0.11.10" +hidapi = "2.6.3" # web: [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen-futures = "0.4.72" +[[bin]] +name = "victor-86bcd-explore" +path = "src/bin/victor_86bcd_explore.rs" + [build-dependencies] # for windows icon embedding winres = "0.1" diff --git a/README.md b/README.md index d211dcf..54c5593 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,15 @@ If you like this, a small donation is appreciated: [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/R6R8DQO8C) RustyMeter is a GUI application written in Rust powered by the awesome egui framework and builds -on the work of @TheHWCave to turn your OWON XDM multimeter into a PC-based powerhouse +on the work of @TheHWCave to turn your OWON XDM or Victor multimeter into a PC-based powerhouse with neat graphing, recording, using it on stream etc. Meters which have been confirmed working already: -- XDM1041 -- XDM1241 -- XDM2041 (except 4W resistance, not yet implemented) +- Owon XDM1041 +- Owon XDM1241 +- Owon XDM2041 (except 4W resistance, not yet implemented) +- Victor 86 series Looking for testers for the XDM3000 series! @@ -20,7 +21,7 @@ Looking for testers for the XDM3000 series! ![recorder](assets/recorder.png) -Eventually, as this is all SCPI based, it could also be extended to other meters that have SCPI interfaces. +Eventually, as this is all SCPI based (except the Victor driver), it could also be extended to other meters that have SCPI interfaces. Maybe some stuff even works out of the box. **NOTE:** This is work and progress and I have more features for this in mind. what works right now is connecting to the multimeter, switching modes and ranges as well as sampling rates. You also get nice graphing for a configureable amount of last samples and you can record samples to CSV, XLSX and JSON. diff --git a/data/victor_serial/Victor86D.png b/data/victor_serial/Victor86D.png new file mode 100644 index 0000000..87ef2e4 Binary files /dev/null and b/data/victor_serial/Victor86D.png differ diff --git a/data/victor_serial/ht1621b.PDF b/data/victor_serial/ht1621b.PDF new file mode 100644 index 0000000..795c52a Binary files /dev/null and b/data/victor_serial/ht1621b.PDF differ diff --git a/data/victor_serial/raw_tx_data.vcd b/data/victor_serial/raw_tx_data.vcd new file mode 100644 index 0000000..e4b66f0 --- /dev/null +++ b/data/victor_serial/raw_tx_data.vcd @@ -0,0 +1,1407 @@ +$date Tue Jul 14 18:21:07 2026 $end +$version libsigrok 0.6.0-git-6027cbff $end +$comment + Acquisition with 1/16 channels at 20 MHz +$end +$timescale 10 ns $end +$scope module libsigrok $end +$var wire 1 ! D0 $end +$upscope $end +$enddefinitions $end + +#0 1! +#26608705 0! +#26619105 1! +#26629500 0! +#26639900 1! +#26650295 0! +#26671090 1! +#26681490 0! +#26691885 1! +#26723080 0! +#26743875 1! +#26754275 0! +#26775070 1! +#26785465 0! +#26816660 1! +#26837455 0! +#26868650 1! +#26879045 0! +#26931035 1! +#26951830 0! +#27045410 1! +#27066205 0! +#27138990 1! +#27149390 0! +#27159785 1! +#27180580 0! +#27190980 1! +#27242970 0! +#27253365 1! +#27263765 0! +#27274160 1! +#27294955 0! +#27305355 1! +#27357345 0! +#27367740 1! +#27378140 0! +#27388535 1! +#27409330 0! +#27419730 1! +#27471720 0! +#27482115 1! +#27523705 0! +#27534105 1! +#27586095 0! +#27596490 1! +#27606890 0! +#27617285 1! +#27638080 0! +#27658880 1! +#27669275 0! +#27710865 1! +#27721265 0! +#27731660 1! +#27752455 0! +#27846035 1! +#27866835 0! +#27960410 1! +#27981210 0! +#28074790 1! +#28095585 0! +#28189165 1! +#28209960 0! +#28303540 1! +#28324335 0! +#28417915 1! +#28438710 0! +#28532290 1! +#28553085 0! +#28584275 1! +#28605075 0! +#28646665 1! +#28667460 0! +#28698650 1! +#28709050 0! +#28761040 1! +#28781835 0! +#28792230 1! +#28802630 0! +#28823425 1! +#28833825 0! +#28854620 1! +#28865015 0! +#28875415 1! +#56065525 0! +#56075925 1! +#56086325 0! +#56096720 1! +#56107120 0! +#56127915 1! +#56138310 0! +#56148710 1! +#56179905 0! +#56200700 1! +#56211095 0! +#56231890 1! +#56242290 0! +#56273480 1! +#56294280 0! +#56325470 1! +#56335870 0! +#56387860 1! +#56408655 0! +#56502235 1! +#56523030 0! +#56595815 1! +#56606210 0! +#56616610 1! +#56637405 0! +#56647800 1! +#56699790 0! +#56710190 1! +#56720585 0! +#56730985 1! +#56751780 0! +#56762175 1! +#56814165 0! +#56824565 1! +#56834960 0! +#56845360 1! +#56866155 0! +#56876550 1! +#56928540 0! +#56938940 1! +#56980530 0! +#56990925 1! +#57042915 0! +#57053315 1! +#57063710 0! +#57074110 1! +#57094905 0! +#57115700 1! +#57126100 0! +#57167690 1! +#57178085 0! +#57188485 1! +#57209280 0! +#57302860 1! +#57323655 0! +#57417235 1! +#57438030 0! +#57531610 1! +#57552405 0! +#57645985 1! +#57666780 0! +#57760360 1! +#57781155 0! +#57874735 1! +#57895530 0! +#57989110 1! +#58009905 0! +#58041100 1! +#58061895 0! +#58103485 1! +#58124280 0! +#58155475 1! +#58165875 0! +#58217860 1! +#58238655 0! +#58249055 1! +#58259450 0! +#58280250 1! +#58290645 0! +#58311440 1! +#58321840 0! +#58332235 1! +#85584735 0! +#85595135 1! +#85605530 0! +#85615930 1! +#85626325 0! +#85647125 1! +#85657520 0! +#85667920 1! +#85699110 0! +#85719905 1! +#85730305 0! +#85751100 1! +#85761500 0! +#85792690 1! +#85813485 0! +#85844680 1! +#85855080 0! +#85907065 1! +#85927860 0! +#86021440 1! +#86042235 0! +#86115020 1! +#86125420 0! +#86135815 1! +#86156615 0! +#86167010 1! +#86219000 0! +#86229395 1! +#86239795 0! +#86250190 1! +#86270990 0! +#86281385 1! +#86333375 0! +#86343770 1! +#86354170 0! +#86364570 1! +#86385365 0! +#86395760 1! +#86447750 0! +#86458145 1! +#86499740 0! +#86510135 1! +#86562125 0! +#86572525 1! +#86582920 0! +#86593320 1! +#86614115 0! +#86634910 1! +#86645305 0! +#86686900 1! +#86697295 0! +#86707695 1! +#86728490 0! +#86822070 1! +#86842865 0! +#86936445 1! +#86957240 0! +#87050820 1! +#87071615 0! +#87165195 1! +#87185990 0! +#87279570 1! +#87300365 0! +#87393945 1! +#87414740 0! +#87508320 1! +#87529115 0! +#87560310 1! +#87581105 0! +#87622695 1! +#87643490 0! +#87674685 1! +#87685080 0! +#87737070 1! +#87757865 0! +#87768265 1! +#87778660 0! +#87799455 1! +#87809855 0! +#87830650 1! +#87841050 0! +#87851445 1! +#115041560 0! +#115051955 1! +#115062355 0! +#115072750 1! +#115083150 0! +#115103945 1! +#115114345 0! +#115124740 1! +#115155935 0! +#115176730 1! +#115187130 0! +#115207925 1! +#115218320 0! +#115249515 1! +#115270310 0! +#115301505 1! +#115311900 0! +#115363890 1! +#115384685 0! +#115478265 1! +#115499060 0! +#115571845 1! +#115582240 0! +#115592640 1! +#115613435 0! +#115623835 1! +#115675820 0! +#115686220 1! +#115696615 0! +#115707015 1! +#115727810 0! +#115738210 1! +#115790195 0! +#115800595 1! +#115810995 0! +#115821390 1! +#115842185 0! +#115852585 1! +#115904570 0! +#115914970 1! +#115956560 0! +#115966960 1! +#116018950 0! +#116029345 1! +#116039745 0! +#116050140 1! +#116070935 0! +#116091730 1! +#116102130 0! +#116143720 1! +#116154120 0! +#116164515 1! +#116185310 0! +#116278890 1! +#116299685 0! +#116393265 1! +#116414060 0! +#116507640 1! +#116528435 0! +#116622015 1! +#116642815 0! +#116736390 1! +#116757190 0! +#116850770 1! +#116871565 0! +#116965145 1! +#116985940 0! +#117017130 1! +#117037925 0! +#117079520 1! +#117100315 0! +#117131505 1! +#117141905 0! +#117193895 1! +#117214690 0! +#117225085 1! +#117235485 0! +#117256280 1! +#117266680 0! +#117287475 1! +#117297870 0! +#117308270 1! +#144519175 0! +#144529575 1! +#144539975 0! +#144550370 1! +#144560770 0! +#144581565 1! +#144591960 0! +#144602360 1! +#144633555 0! +#144654350 1! +#144664745 0! +#144685540 1! +#144695940 0! +#144727130 1! +#144747930 0! +#144779120 1! +#144789520 0! +#144841505 1! +#144862305 0! +#144955885 1! +#144976680 0! +#145049460 1! +#145059860 0! +#145070260 1! +#145091055 0! +#145101450 1! +#145153440 0! +#145163840 1! +#145174235 0! +#145184635 1! +#145205430 0! +#145215825 1! +#145267815 0! +#145278215 1! +#145288610 0! +#145299010 1! +#145319805 0! +#145330200 1! +#145382190 0! +#145392590 1! +#145434180 0! +#145444575 1! +#145496565 0! +#145506965 1! +#145517360 0! +#145527760 1! +#145548555 0! +#145569350 1! +#145579750 0! +#145621340 1! +#145631735 0! +#145642135 1! +#145662930 0! +#145756510 1! +#145777305 0! +#145870885 1! +#145891680 0! +#145985260 1! +#146006055 0! +#146099635 1! +#146120430 0! +#146214010 1! +#146234805 0! +#146328385 1! +#146349180 0! +#146442760 1! +#146463555 0! +#146494750 1! +#146515545 0! +#146557135 1! +#146577930 0! +#146609125 1! +#146619525 0! +#146671510 1! +#146692305 0! +#146702705 1! +#146713100 0! +#146733900 1! +#146744295 0! +#146765090 1! +#146775490 0! +#146785885 1! +#174007195 0! +#174017590 1! +#174027990 0! +#174038385 1! +#174048785 0! +#174069580 1! +#174079975 0! +#174090375 1! +#174121570 0! +#174142365 1! +#174152760 0! +#174173555 1! +#174183955 0! +#174215150 1! +#174235945 0! +#174267135 1! +#174277535 0! +#174329525 1! +#174350320 0! +#174443900 1! +#174464695 0! +#174537480 1! +#174547875 0! +#174558275 1! +#174579070 0! +#174589465 1! +#174641455 0! +#174651855 1! +#174662250 0! +#174672650 1! +#174693445 0! +#174703840 1! +#174755830 0! +#174766230 1! +#174776625 0! +#174787025 1! +#174807820 0! +#174818220 1! +#174870205 0! +#174880605 1! +#174922195 0! +#174932595 1! +#174984580 0! +#174994980 1! +#175005375 0! +#175015775 1! +#175036570 0! +#175057365 1! +#175067765 0! +#175109355 1! +#175119750 0! +#175130150 1! +#175150945 0! +#175244525 1! +#175265320 0! +#175358900 1! +#175379695 0! +#175473275 1! +#175494070 0! +#175587650 1! +#175608445 0! +#175702025 1! +#175722820 0! +#175816400 1! +#175837195 0! +#175930775 1! +#175951570 0! +#175982765 1! +#176003560 0! +#176045150 1! +#176065950 0! +#176097140 1! +#176107540 0! +#176159525 1! +#176180325 0! +#176190720 1! +#176201120 0! +#176221915 1! +#176232310 0! +#176253105 1! +#176263505 0! +#176273905 1! +#203495210 0! +#203505605 1! +#203516005 0! +#203526400 1! +#203536800 0! +#203557595 1! +#203567995 0! +#203578390 1! +#203609585 0! +#203630380 1! +#203640780 0! +#203661575 1! +#203671970 0! +#203703165 1! +#203723960 0! +#203755155 1! +#203765550 0! +#203817540 1! +#203838335 0! +#203931915 1! +#203952710 0! +#204025495 1! +#204035890 0! +#204046290 1! +#204067085 0! +#204077485 1! +#204129470 0! +#204139870 1! +#204150265 0! +#204160665 1! +#204181460 0! +#204191860 1! +#204243845 0! +#204254245 1! +#204264645 0! +#204275040 1! +#204295835 0! +#204306235 1! +#204358220 0! +#204368620 1! +#204410210 0! +#204420610 1! +#204472600 0! +#204482995 1! +#204493395 0! +#204503790 1! +#204524585 0! +#204545380 1! +#204555780 0! +#204597370 1! +#204607770 0! +#204618165 1! +#204638960 0! +#204732540 1! +#204753335 0! +#204846915 1! +#204867710 0! +#204961290 1! +#204982085 0! +#205075665 1! +#205096465 0! +#205190040 1! +#205210840 0! +#205304420 1! +#205325215 0! +#205418795 1! +#205439590 0! +#205470780 1! +#205491575 0! +#205533170 1! +#205553965 0! +#205585155 1! +#205595555 0! +#205647545 1! +#205668340 0! +#205678735 1! +#205689135 0! +#205709930 1! +#205720330 0! +#205741125 1! +#205751520 0! +#205761920 1! +#232983225 0! +#232993625 1! +#233004020 0! +#233014420 1! +#233024815 0! +#233045610 1! +#233056010 0! +#233066405 1! +#233097600 0! +#233118395 1! +#233128795 0! +#233149590 1! +#233159985 0! +#233191180 1! +#233211975 0! +#233243170 1! +#233253565 0! +#233305555 1! +#233326350 0! +#233419930 1! +#233440725 0! +#233513510 1! +#233523910 0! +#233534305 1! +#233555100 0! +#233565500 1! +#233617490 0! +#233627885 1! +#233638285 0! +#233648680 1! +#233669475 0! +#233679875 1! +#233731865 0! +#233742260 1! +#233752660 0! +#233763055 1! +#233783850 0! +#233794250 1! +#233846240 0! +#233856635 1! +#233898225 0! +#233908625 1! +#233960615 0! +#233971010 1! +#233981410 0! +#233991805 1! +#234012600 0! +#234033400 1! +#234043795 0! +#234085385 1! +#234095785 0! +#234106180 1! +#234126980 0! +#234220555 1! +#234241355 0! +#234334935 1! +#234355730 0! +#234449310 1! +#234470105 0! +#234563685 1! +#234584480 0! +#234678060 1! +#234698855 0! +#234792435 1! +#234813230 0! +#234906810 1! +#234927605 0! +#234958800 1! +#234979595 0! +#235021185 1! +#235041980 0! +#235073175 1! +#235083570 0! +#235135560 1! +#235156355 0! +#235166755 1! +#235177150 0! +#235197945 1! +#235208345 0! +#235229140 1! +#235239535 0! +#235249935 1! +#262460845 0! +#262471240 1! +#262481640 0! +#262492035 1! +#262502435 0! +#262523230 1! +#262533630 0! +#262544025 1! +#262575220 0! +#262596015 1! +#262606410 0! +#262627205 1! +#262637605 0! +#262668800 1! +#262689595 0! +#262720785 1! +#262731185 0! +#262783175 1! +#262803970 0! +#262897550 1! +#262918345 0! +#262991130 1! +#263001525 0! +#263011925 1! +#263032720 0! +#263043115 1! +#263095105 0! +#263105505 1! +#263115900 0! +#263126300 1! +#263147095 0! +#263157495 1! +#263209480 0! +#263219880 1! +#263230275 0! +#263240675 1! +#263261470 0! +#263271870 1! +#263323855 0! +#263334255 1! +#263375845 0! +#263386245 1! +#263438230 0! +#263448630 1! +#263459025 0! +#263469425 1! +#263490220 0! +#263511015 1! +#263521415 0! +#263563005 1! +#263573405 0! +#263583800 1! +#263604595 0! +#263698175 1! +#263718970 0! +#263812550 1! +#263833345 0! +#263926925 1! +#263947720 0! +#264041300 1! +#264062095 0! +#264155675 1! +#264176470 0! +#264270050 1! +#264290845 0! +#264384425 1! +#264405225 0! +#264436415 1! +#264457210 0! +#264498800 1! +#264519600 0! +#264550790 1! +#264561190 0! +#264613175 1! +#264633975 0! +#264644370 1! +#264654770 0! +#264675565 1! +#264685960 0! +#264706755 1! +#264717155 0! +#264727555 1! +#291948860 0! +#291959255 1! +#291969655 0! +#291980055 1! +#291990450 0! +#292011245 1! +#292021645 0! +#292032040 1! +#292063235 0! +#292084030 1! +#292094430 0! +#292115225 1! +#292125620 0! +#292156815 1! +#292177610 0! +#292208805 1! +#292219200 0! +#292271190 1! +#292291985 0! +#292385565 1! +#292406360 0! +#292479145 1! +#292489540 0! +#292499940 1! +#292520735 0! +#292531135 1! +#292583120 0! +#292593520 1! +#292603920 0! +#292614315 1! +#292635110 0! +#292645510 1! +#292697495 0! +#292707895 1! +#292718295 0! +#292728690 1! +#292749485 0! +#292759885 1! +#292811875 0! +#292822270 1! +#292863860 0! +#292874260 1! +#292926250 0! +#292936645 1! +#292947045 0! +#292957440 1! +#292978235 0! +#292999030 1! +#293009430 0! +#293051020 1! +#293061420 0! +#293071815 1! +#293092610 0! +#293186190 1! +#293206985 0! +#293300565 1! +#293321360 0! +#293414940 1! +#293435740 0! +#293529315 1! +#293550115 0! +#293643695 1! +#293664490 0! +#293758070 1! +#293778865 0! +#293872445 1! +#293893240 0! +#293924430 1! +#293945225 0! +#293986820 1! +#294007615 0! +#294038805 1! +#294049205 0! +#294101195 1! +#294121990 0! +#294132385 1! +#294142785 0! +#294163580 1! +#294173980 0! +#294194775 1! +#294205170 0! +#294215570 1! +#321436875 0! +#321447275 1! +#321457670 0! +#321468070 1! +#321478465 0! +#321499260 1! +#321509660 0! +#321520055 1! +#321551250 0! +#321572045 1! +#321582445 0! +#321603240 1! +#321613635 0! +#321644830 1! +#321665625 0! +#321696820 1! +#321707215 0! +#321759205 1! +#321780000 0! +#321873580 1! +#321894375 0! +#321967160 1! +#321977560 0! +#321987955 1! +#322008750 0! +#322019150 1! +#322071140 0! +#322081535 1! +#322091935 0! +#322102330 1! +#322123125 0! +#322133525 1! +#322185515 0! +#322195910 1! +#322206310 0! +#322216705 1! +#322237500 0! +#322247900 1! +#322299890 0! +#322310285 1! +#322351875 0! +#322362275 1! +#322414265 0! +#322424660 1! +#322435060 0! +#322445455 1! +#322466255 0! +#322487050 1! +#322497445 0! +#322539035 1! +#322549435 0! +#322559830 1! +#322580630 0! +#322674210 1! +#322695005 0! +#322788585 1! +#322809380 0! +#322902960 1! +#322923755 0! +#323017335 1! +#323038130 0! +#323131710 1! +#323152505 0! +#323246085 1! +#323266880 0! +#323360460 1! +#323381255 0! +#323412450 1! +#323433245 0! +#323474835 1! +#323495630 0! +#323526825 1! +#323537220 0! +#323589210 1! +#323610005 0! +#323620405 1! +#323630800 0! +#323651595 1! +#323661995 0! +#323682790 1! +#323693185 0! +#323703585 1! +#350914495 0! +#350924890 1! +#350935290 0! +#350945685 1! +#350956085 0! +#350976880 1! +#350987280 0! +#350997675 1! +#351028870 0! +#351049665 1! +#351060060 0! +#351080860 1! +#351091255 0! +#351122450 1! +#351143245 0! +#351174435 1! +#351184835 0! +#351236825 1! +#351257620 0! +#351351200 1! +#351371995 0! +#351444780 1! +#351455175 0! +#351465575 1! +#351486370 0! +#351496770 1! +#351548755 0! +#351559155 1! +#351569550 0! +#351579950 1! +#351600745 0! +#351611145 1! +#351663130 0! +#351673530 1! +#351683925 0! +#351694325 1! +#351715120 0! +#351725520 1! +#351777505 0! +#351787905 1! +#351829495 0! +#351839895 1! +#351891880 0! +#351902280 1! +#351912680 0! +#351923075 1! +#351943870 0! +#351964665 1! +#351975065 0! +#352016655 1! +#352027055 0! +#352037450 1! +#352058245 0! +#352151825 1! +#352172620 0! +#352266200 1! +#352286995 0! +#352380575 1! +#352401370 0! +#352494950 1! +#352515745 0! +#352609325 1! +#352630120 0! +#352723700 1! +#352744500 0! +#352838075 1! +#352858875 0! +#352890065 1! +#352910860 0! +#352952455 1! +#352973250 0! +#353004440 1! +#353014840 0! +#353066830 1! +#353087625 0! +#353098020 1! +#353108420 0! +#353129215 1! +#353139610 0! +#353160410 1! +#353170805 0! +#353181205 1! +#380402510 0! +#380412905 1! +#380423305 0! +#380433705 1! +#380444100 0! +#380464895 1! +#380475295 0! +#380485690 1! +#380516885 0! +#380537680 1! +#380548080 0! +#380568875 1! +#380579270 0! +#380610465 1! +#380631260 0! +#380662455 1! +#380672850 0! +#380724840 1! +#380745635 0! +#380839215 1! +#380860010 0! +#380932795 1! +#380943195 0! +#380953590 1! +#380974385 0! +#380984785 1! +#381036770 0! +#381047170 1! +#381057570 0! +#381067965 1! +#381088760 0! +#381099160 1! +#381151150 0! +#381161545 1! +#381171945 0! +#381182340 1! +#381203135 0! +#381213535 1! +#381265525 0! +#381275920 1! +#381317510 0! +#381327910 1! +#381379900 0! +#381390295 1! +#381400695 0! +#381411090 1! +#381431885 0! +#381452680 1! +#381463080 0! +#381504670 1! +#381515070 0! +#381525465 1! +#381546260 0! +#381639840 1! +#381660635 0! +#381754215 1! +#381775015 0! +#381868590 1! +#381889390 0! +#381982965 1! +#382003765 0! +#382097345 1! +#382118140 0! +#382211720 1! +#382232515 0! +#382326095 1! +#382346890 0! +#382378080 1! +#382398875 0! +#382440470 1! +#382461265 0! +#382492455 1! +#382502855 0! +#382554845 1! +#382575640 0! +#382586035 1! +#382596435 0! +#382617230 1! +#382627630 0! +#382648425 1! +#382658820 0! +#382669220 1! +#409890525 0! +#409900925 1! +#409911320 0! +#409921720 1! +#409932115 0! +#409952910 1! +#409963310 0! +#409973710 1! +#410004900 0! +#410025695 1! +#410036095 0! +#410056890 1! +#410067285 0! +#410098480 1! +#410119275 0! +#410150470 1! +#410160865 0! +#410212855 1! +#410233650 0! +#410327230 1! +#410348025 0! +#410420810 1! +#410431210 0! +#410441605 1! +#410462400 0! +#410472800 1! +#410524790 0! +#410535185 1! +#410545585 0! +#410555980 1! +#410576775 0! +#410587175 1! +#410639165 0! +#410649560 1! +#410659960 0! +#410670355 1! +#410691150 0! +#410701550 1! +#410753540 0! +#410763935 1! +#410805530 0! +#410815925 1! +#410867915 0! +#410878310 1! +#410888710 0! +#410899105 1! +#410919905 0! +#410940700 1! +#410951095 0! +#410992685 1! +#411003085 0! +#411013485 1! +#411034280 0! +#411127860 1! +#411148655 0! +#411242235 1! +#411263030 0! +#411356610 1! +#411377405 0! +#411470985 1! +#411491780 0! +#411585360 1! +#411606155 0! +#411699735 1! +#411720530 0! +#411814110 1! +#411834905 0! +#411866100 1! +#411886895 0! +#411928485 1! +#411949280 0! +#411980475 1! +#411990870 0! +#412042860 1! +#412063655 0! +#412074055 1! +#412084450 0! +#412105245 1! +#412115645 0! +#412136440 1! +#412146835 0! +#412157235 1! +#439368145 0! +#439378540 1! +#439388940 0! +#439399335 1! +#439409735 0! +#439430530 1! +#439440930 0! +#439451325 1! +#439482520 0! +#439503315 1! +#439513710 0! +#439534510 1! +#439544905 0! +#439576100 1! +#439596895 0! +#439628090 1! +#439638485 0! +#439690475 1! +#439711270 0! +#439804850 1! +#439825645 0! +#439898430 1! +#439908825 0! +#439919225 1! +#439940020 0! +#439950420 1! +#440002405 0! +#440012805 1! +#440023200 0! +#440033600 1! +#440054395 0! +#440064795 1! +#440116780 0! +#440127180 1! +#440137575 0! +#440147975 1! +#440168770 0! +#440179170 1! +#440231155 0! +#440241555 1! +#440283145 0! +#440293545 1! +#440345530 0! +#440355930 1! +#440366330 0! +#440376725 1! +#440397520 0! +#440418315 1! +#440428715 0! +#440470305 1! +#440480705 0! +#440491100 1! +#440511895 0! +#440605475 1! +#440626270 0! +#440719850 1! +#440740645 0! +#440834225 1! +#440855020 0! +#440948600 1! +#440969395 0! +#441062975 1! +#441083775 0! +#441177350 1! +#441198150 0! +#441291725 1! +#441312525 0! +#441343715 1! +#441364510 0! +#441406105 1! +#441426900 0! +#441458090 1! +#441468490 0! +#441520480 1! +#441541275 0! +#441551670 1! +#441562070 0! +#441582865 1! +#441593260 0! +#441614060 1! +#441624455 0! +#441634855 1! +#468856160 0! +#468866560 1! +#468876955 0! +#468887355 1! +#468897750 0! +#468918545 1! +#468928945 0! +#468939340 1! +#468970535 0! +#468991330 1! +#469001730 0! +#469022525 1! +#469032920 0! +#469064115 1! +#469084910 0! +#469116105 1! +#469126500 0! +#469178490 1! +#469199285 0! +#469292865 1! +#469313660 0! +#469386445 1! +#469396845 0! +#469407240 1! +#469428035 0! +#469438435 1! +#469490425 0! +#469500820 1! +#469511220 0! +#469521615 1! +#469542410 0! +#469552810 1! +#469604800 0! +#469615195 1! +#469625595 0! +#469635990 1! +#469656785 0! +#469667185 1! +#469719175 0! +#469729570 1! +#469771160 0! +#469781560 1! +#469833550 0! +#469843945 1! +#469854345 0! +#469864740 1! +#469885535 0! +#469906335 1! +#469916730 0! +#469958320 1! +#469968720 0! +#469979115 1! +#469999910 0! +#470093490 1! +#470114290 0! +#470207865 1! +#470228665 0! +#470322245 1! +#470343040 0! +#470436620 1! +#470457415 0! +#470550995 1! +#470571790 0! +#470665370 1! +#470686165 0! +#470779745 1! +#470800540 0! +#470831730 1! +#470852530 0! +#470894120 1! +#470914915 0! +#470946110 1! +#470956505 0! +#471008495 1! +#471029290 0! +#471039685 1! +#471050085 0! +#471070880 1! +#471081280 0! +#471102075 1! +#471112470 0! +#471122870 1! +#498999235 0! +#499009630 1! +#499020030 0! +#499030425 1! +#499040825 0! +#499061620 1! +#499072020 0! +#499082415 1! +#499113610 0! +#499134405 1! +#499144805 0! +#499165600 1! +#499175995 0! +#499207190 1! +#499227985 0! +#499259180 1! +#499269575 0! +#499321565 1! +#499342360 0! +#499435940 1! +#499456735 0! +#499529520 1! +#499539915 0! +#499550315 1! +#499571110 0! +#499581510 1! +#499633495 0! +#499643895 1! +#499654290 0! +#499664690 1! +#499685485 0! +#499695885 1! +#499747870 0! +#499758270 1! +#499768670 0! +#499779065 1! +#499799860 0! +#499810260 1! +#499862245 0! +#499872645 1! +#499914235 0! +#499924635 1! +#499976625 0! +#499987020 1! +#499997420 0! +#500007815 1! +#500028610 0! +#500049405 1! +#500059805 0! +#500101395 1! +#500111795 0! +#500122190 1! +#500142985 0! +#500236565 1! +#500257360 0! +#500350940 1! +#500371735 0! +#500465315 1! +#500486110 0! +#500579690 1! +#500600490 0! +#500694065 1! +#500714865 0! +#500808445 1! +#500829240 0! +#500922820 1! +#500943615 0! +#500974805 1! +#500995600 0! +#501037195 1! +#501057990 0! +#501089180 1! +#501099580 0! +#501151570 1! +#501172365 0! +#501182760 1! +#501193160 0! +#501213955 1! +#501224355 0! +#501245150 1! +#501255545 0! +#501265945 1! +#507617270 diff --git a/data/victor_serial/victor_serial_samples.csv b/data/victor_serial/victor_serial_samples.csv new file mode 100644 index 0000000..bf8c356 --- /dev/null +++ b/data/victor_serial/victor_serial_samples.csv @@ -0,0 +1,18 @@ +timestamp,function,unit,dp_mode,d0,d1,d2,d3,dp_after,notes,duration_ms,byte_count,raw_hex +2026-07-14T20:11:26.673,VDC,mV,auto,0,0,0,0,2,,1000,79,"12 0c 00 10 5f df 5f 5f 42 00 00 00 00 00 00 00 0c 04 21 a5 12 0c 00 10 5f df 5f 5f 42 00 00 00 00 00 00 00 0c 04 21 a5 12 0c 00 10 5f df 5f 5f 42 00 00 00 00 00 00 00 0c 04 21 a5 12 0c 00 10 5f df 5f 5f 42 00 00 00 00 00 00 00 0c 04 21" +2026-07-14T20:14:11.795,VDC,mV,auto,0,0,0,0,2,,1000,79,"12 0c 00 10 5f df 5f 5f 42 00 00 00 00 00 00 00 0c 04 21 a5 12 0c 00 10 5f df 5f 5f 42 00 00 00 00 00 00 00 0c 04 21 a5 12 0c 00 10 5f df 5f 5f 42 00 00 00 00 00 00 00 0c 04 21 a5 12 0c 00 10 5f df 5f 5f 42 00 00 00 00 00 00 00 0c 04 21" +2026-07-14T20:15:01.028,VDC,V,manual,0,0,0,0,0,,1000,72,"df 42 00 00 00 00 00 00 00 0c 04 49 a5 12 04 00 40 5f 5f 5f df 42 00 00 00 00 00 00 00 0c 04 49 a5 12 04 00 40 5f 5f 5f df 42 00 00 00 00 00 00 00 0c 04 49 a5 12 04 00 40 5f 5f 5f df 42 00 00 00 00 00 00 00 0c 04 49" +2026-07-14T20:15:10.952,VDC,V,manual,0,0,0,0,1,,1000,78,"04 00 40 5f 5f df 5f 42 00 00 00 00 00 00 00 0c 04 49 a5 12 04 00 40 5f 5f df 5f 42 00 00 00 00 00 00 00 0c 04 49 a5 12 04 00 40 5f 5f df 5f 42 00 00 00 00 00 00 00 0c 04 49 a5 12 04 00 40 5f 5f df 5f 42 00 00 00 00 00 00 00 0c 04 49" +2026-07-14T20:15:25.400,VDC,V,manual,0,0,0,0,2,,1000,80,"a5 12 04 00 40 5f df 5f 5f 42 00 00 00 00 00 00 00 0c 04 49 a5 12 04 00 40 5f df 5f 5f 42 00 00 00 00 00 00 00 0c 04 49 a5 12 04 00 40 5f df 5f 5f 42 00 00 00 00 00 00 00 0c 04 49 a5 12 04 00 40 5f df 5f 5f 42 00 00 00 00 00 00 00 0c 04 49" +2026-07-14T20:15:45.255,VDC,V,manual,0,0,0,0,_,,1000,80,"a5 12 04 00 40 5f 5f 5f 5f 42 00 00 00 00 00 00 00 0c 04 c9 a5 12 04 00 40 5f 5f 5f 5f 42 00 00 00 00 00 00 00 0c 04 c9 a5 12 04 00 40 5f 5f 5f 5f 42 00 00 00 00 00 00 00 0c 04 c9 a5 12 04 00 40 5f 5f 5f 5f 42 00 00 00 00 00 00 00 0c 04 c9" +2026-07-14T20:16:41.176,TEMP,°C,auto,_,_,3,0,_,no manual/auto range in temp mode,1000,78,"00 20 00 5f 79 5f 5f 40 00 00 00 00 00 00 00 0c 04 bd a5 12 00 20 00 5f 79 5f 5f 40 00 00 00 00 00 00 00 0c 04 bd a5 12 00 20 00 5f 79 5f 5f 40 00 00 00 00 00 00 00 0c 04 bd a5 12 00 20 00 5f 79 5f 5f 40 00 00 00 00 00 00 00 0c 04 bd" +2026-07-14T20:17:08.892,TEMP,°F,auto,_,_,8,5,_,no manual/auto range in temp mode,1000,79,"12 00 10 00 6b 7f 5f 5f 40 00 00 00 00 00 00 00 0c 04 bf a5 12 00 10 00 6b 7f 5f 5f 40 00 00 00 00 00 00 00 0c 04 bf a5 12 00 10 00 6b 7f 5f 5f 40 00 00 00 00 00 00 00 0c 04 bf a5 12 00 10 00 6b 7f 5f 5f 40 00 00 00 00 00 00 00 0c 04 bf" +2026-07-14T20:18:15.528,CONT,Ω,auto,_,0,L,_,2,no manual/auto range in cont mode,1000,80,"a5 12 20 00 08 00 8e 5f 00 40 ff ff ff ff ff ff ff fc 04 05 a5 12 20 00 08 00 8e 5f 00 40 ff ff ff ff ff ff ff fc 04 05 a5 12 20 00 08 00 8e 5f 00 40 ff ff ff ff ff ff ff fc 04 05 a5 12 20 00 08 00 8e 5f 00 40 ff ff ff ff ff ff ff fc 04 05" +2026-07-14T20:18:58.750,DIOD,V,auto,_,0,L,_,0,no manual/auto range in diod mode,1000,80,"04 00 04 00 0e 5f 80 40 ff ff ff ff ff ff ff fc 04 e5 a5 12 04 00 04 00 0e 5f 80 40 ff ff ff ff ff ff ff fc 04 e5 a5 12 04 00 04 00 0e 5f 80 40 ff ff ff ff ff ff ff fc 04 e5 a5 12 04 00 04 00 0e 5f 80 40 ff ff ff ff ff ff ff fc 04 e5 a5 12" +2026-07-14T20:19:33.542,RES,MΩ,auto,_,0,L,_,1,,1000,61,"a5 12 a0 00 10 00 0e df 00 40 ff ff ff ff ff ff ff fc 04 8d a5 12 a0 00 10 00 0e df 00 40 ff ff ff ff ff ff ff fc 04 8d a5 12 a0 00 10 00 0e df 00 40 ff ff ff ff ff ff ff fc 04 8d a5" +2026-07-14T20:20:49.317,VDC,V,auto,2,1,4,0,1,,1000,81,"a5 12 04 00 10 5f 72 d0 3d 42 00 00 00 00 01 ff ff fc 04 ea a5 12 04 00 10 5f 72 d0 3d 42 00 00 00 00 01 ff ff fc 04 ea a5 12 04 00 10 5f 72 d0 3d 42 00 00 00 00 01 ff ff fc 04 ea a5 12 04 00 10 5f 72 d0 3d 42 00 00 00 00 01 ff ff fc 04 ea a5" +2026-07-14T20:21:22.757,VDC,V,auto,2,1,3,7,1,,1000,78,"04 00 10 51 79 d0 3d 42 00 00 00 00 01 ff ff fc 04 e3 a5 12 04 00 10 51 79 d0 3d 42 00 00 00 00 01 ff ff fc 04 e3 a5 12 04 00 10 51 79 d0 3d 42 00 00 00 00 01 ff ff fc 04 e3 a5 12 04 00 10 51 79 d0 3d 42 00 00 00 00 01 ff ff fc 04 e3" +2026-07-14T20:21:48.998,VDC,V,auto,2,1,2,8,1,,1000,80,"a5 12 04 00 10 7f 3d d0 3d 42 00 00 00 00 01 ff ff fc 04 d5 a5 12 04 00 10 7f 3d d0 3d 42 00 00 00 00 01 ff ff fc 04 d5 a5 12 04 00 10 7f 3d d0 3d 42 00 00 00 00 01 ff ff fc 04 d5 a5 12 04 00 10 7f 3d d0 3d 42 00 00 00 00 01 ff ff fc 04 d5" +2026-07-14T20:22:16.594,VDC,V,auto,2,1,3,9,1,,1000,80,"00 10 7b 79 d0 3d 42 00 00 00 00 01 ff ff fc 04 0d a5 12 04 00 10 7b 79 d0 3d 42 00 00 00 00 01 ff ff fc 04 0d a5 12 04 00 10 7b 79 d0 3d 42 00 00 00 00 01 ff ff fc 04 0d a5 12 04 00 10 7b 79 d0 3d 42 00 00 00 00 01 ff ff fc 04 0d a5 12 04" +2026-07-14T21:33:44.732,VDC,V,auto,2,1,1,9,1,the value is minus/negative,1000,62,"a5 12 04 00 10 7b 50 d0 3d 46 00 00 00 00 01 ff ff fe 04 ea a5 12 04 00 10 7b 50 d0 3d 46 00 00 00 00 01 ff ff fe 04 ea a5 12 04 00 10 7b 50 d0 3d 46 00 00 00 00 01 ff ff fe 04 ea a5 12" +2026-07-14T21:53:40.931,CAP,µF,auto,4,6,2,6,2,,1000,79,"01 80 10 6f bd 6f 72 40 00 03 ff ff ff ff ff fc 04 93 a5 12 01 80 10 6f bd 6f 72 40 00 03 ff ff ff ff ff fc 04 93 a5 12 01 80 10 6f bd 6f 72 40 00 03 ff ff ff ff ff fc 04 93 a5 12 01 80 10 6f bd 6f 72 40 00 03 ff ff ff ff ff fc 04 93 a5" diff --git a/src/app/graph.rs b/src/app/graph.rs index fff3030..a39ff10 100644 --- a/src/app/graph.rs +++ b/src/app/graph.rs @@ -99,8 +99,15 @@ pub fn show_histogram( hist_mem_depth_max: usize, ) { // Format the latest measurement for display - let (_formatted_value, display_unit) = - crate::helpers::format_measurement(curr_meas, 10, 1_000_000.0, 0.0001, &metermode, false); + let (_formatted_value, display_unit) = crate::helpers::format_measurement( + curr_meas, + 10, + 1_000_000.0, + 0.0001, + &metermode, + false, + None, + ); // Create bar chart data let hist_values_vec: Vec = hist_values.iter().copied().collect(); @@ -171,6 +178,7 @@ pub fn show_histogram( 0.0001, &metermode, false, + None, ); let chart_name = format!( " Samples: {}\nBin Width: {} {}\n Min: {}\n Max: {}", @@ -218,6 +226,7 @@ pub fn show_histogram( 0.0001, &metermode, false, + None, ); let (formatted_end, _) = crate::helpers::format_measurement( bin_end, @@ -226,6 +235,7 @@ pub fn show_histogram( 0.0001, &metermode, false, + None, ); // Sample count is the bar's value (height) let sample_count = bar.value as usize; diff --git a/src/app/hid.rs b/src/app/hid.rs new file mode 100644 index 0000000..b9c7e2e --- /dev/null +++ b/src/app/hid.rs @@ -0,0 +1,145 @@ +use std::ffi::CString; +use std::time::Duration; + +use hidapi::HidApi; +use tokio::sync::{mpsc, oneshot}; + +use crate::multimeter::MeterMode; +use crate::victor_fs9922::{self, VICTOR_PRODUCT_ID, VICTOR_VENDOR_ID}; + +impl super::MyApp { + pub fn refresh_hid_devices(&mut self) { + self.hid_devicelist.clear(); + match HidApi::new() { + Ok(api) => { + for device in api.device_list() { + if device.vendor_id() == VICTOR_VENDOR_ID + && device.product_id() == VICTOR_PRODUCT_ID + { + let path = device.path().to_string_lossy().into_owned(); + let label = format!( + "{} {} ({:04x}:{:04x})", + device.manufacturer_string().unwrap_or("Victor"), + device.product_string().unwrap_or("Multimeter"), + device.vendor_id(), + device.product_id(), + ); + self.hid_devicelist.push_back((path, label)); + } + } + } + Err(e) => { + if self.value_debug { + println!("Failed to enumerate HID devices: {}", e); + } + } + } + if self.hid_device_path.is_empty() { + if let Some((path, _)) = self.hid_devicelist.front() { + self.hid_device_path = path.clone(); + } + } + } + + pub fn spawn_hid_task(&mut self) { + let (tx_data, rx_data) = mpsc::channel::>(100); + let (tx_mode, rx_mode) = mpsc::channel::<(MeterMode, String)>(10); + let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>(); + + self.serial_rx = Some(rx_data); + self.mode_rx = Some(rx_mode); + self.shutdown_tx = Some(shutdown_tx); + // Victor meters are read-only; no command channel. + self.serial_tx = None; + + let device_path = self.hid_device_path.clone(); + let value_debug_shared = self.value_debug_shared.clone(); + let poll_interval_shared = self.poll_interval_shared.clone(); + let device_shared = self.device.clone(); + + tokio::task::spawn_blocking(move || { + let api = match HidApi::new() { + Ok(api) => api, + Err(e) => { + if *value_debug_shared.lock().unwrap() { + println!("Failed to create HID API: {}", e); + } + return; + } + }; + + let c_path = match CString::new(device_path.as_bytes()) { + Ok(path) => path, + Err(e) => { + if *value_debug_shared.lock().unwrap() { + println!("Invalid Victor HID device path: {}", e); + } + return; + } + }; + let device = match api.open_path(&c_path) { + Ok(device) => device, + Err(e) => { + if *value_debug_shared.lock().unwrap() { + println!("Failed to open Victor HID device: {}", e); + } + return; + } + }; + + { + let mut dev = device_shared.lock().unwrap(); + *dev = "Victor 86 series (read only)".to_owned(); + } + + if *value_debug_shared.lock().unwrap() { + println!("Victor HID device opened"); + } + + let mut readbuf = [0u8; 64]; + let mut shutting_down = false; + let mut last_mode = None::; + + loop { + if shutdown_rx.try_recv().is_ok() { + shutting_down = true; + } + if shutting_down { + break; + } + + let interval = *poll_interval_shared.lock().unwrap(); + let timeout_ms = interval.max(50) as i32; + + match device.read_timeout(&mut readbuf, timeout_ms) { + Ok(0) => continue, + Ok(len) => { + if *value_debug_shared.lock().unwrap() { + println!("Victor HID received {} bytes", len); + } + if let Some(reading) = victor_fs9922::parse_hid_buffer(&readbuf[..len]) { + if *value_debug_shared.lock().unwrap() { + println!("Victor reading: {} {:?}", reading.value, reading.mode); + } + let _ = tx_data.blocking_send(Some(reading.value)); + if last_mode != Some(reading.mode) { + last_mode = Some(reading.mode); + let _ = tx_mode.blocking_send((reading.mode, reading.unit)); + } + } + } + Err(e) => { + if *value_debug_shared.lock().unwrap() { + println!("Victor HID read error: {}", e); + } + std::thread::sleep(Duration::from_millis(interval)); + } + } + } + + if *value_debug_shared.lock().unwrap() { + println!("Victor HID task shutting down"); + } + }); + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 010c7e0..154fa53 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -15,10 +15,68 @@ use crate::multimeter::{MeterMode, RangeCmd, RateCmd, ScpiMode}; // Submodules for split impl blocks mod graph; +#[cfg(not(target_arch = "wasm32"))] +mod hid; mod recording; mod serial; mod settings; mod ui; +#[cfg(not(target_arch = "wasm32"))] +mod victor_86bcd_capture_ui; +#[cfg(not(target_arch = "wasm32"))] +mod victor_readonly_serial; + +/// How rusty_meter talks to the multimeter. +/// +/// - `ScpiSerial` — SCPI over UART (OWON XDM series, remote control) +/// - `VictorHid` — **legacy** Victor 86B/C/D via USB HID + FS9922 cable (discontinued) +/// - `Victor86bcdSerial` — **newer** Victor (e.g. 86D): DM1107, opto-isolated CP2102 serial +/// - `Victor86eSerial` — Victor 86E via CP2102 UART + ES51932 ASCII frames (read only) +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum ConnectionType { + #[default] + ScpiSerial, + /// Legacy Victor 86B/C/D: USB HID, Fortune FS9922-DMM4. See `victor_fs9922` / sigrok wiki. + #[cfg(not(target_arch = "wasm32"))] + VictorHid, + /// Newer Victor (e.g. 86D): DM1107, 9600 8N1 serial over opto-isolated USB. See `victor_dm1107`. + #[cfg(not(target_arch = "wasm32"))] + Victor86bcdSerial, + /// Victor 86E: CP2102 serial 19200 7o1, Cyrustek ES51932. See `victor_es519xx` module. + #[cfg(not(target_arch = "wasm32"))] + Victor86eSerial, +} + +/// Victor 86D / DM1107: 9600 baud, 8 data bits, no parity, 1 stop (8N1). +/// Line settings must be set on the builder before open — post-open `set_*` is unreliable. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn open_victor_8n1_serial( + path: &str, + baud: u32, +) -> Result { + use mio_serial::{DataBits, Parity, SerialPortBuilderExt, StopBits}; + + mio_serial::new(path, baud) + .data_bits(DataBits::Eight) + .parity(Parity::None) + .stop_bits(StopBits::One) + .open_native_async() +} + +/// Victor 86E / ES51932: 19200 baud, 7 data bits, odd parity, 1 stop (7o1). +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn open_victor_7o1_serial( + path: &str, + baud: u32, +) -> Result { + use mio_serial::{DataBits, Parity, SerialPortBuilderExt, StopBits}; + + mio_serial::new(path, baud) + .data_bits(DataBits::Seven) + .parity(Parity::Odd) + .stop_bits(StopBits::One) + .open_native_async() +} const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -55,16 +113,28 @@ pub struct Record { pub value: f64, } -#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] struct ModeDisplaySettings { - pub auto_scale_units: bool, // true = use mV / mΩ / kΩ etc. (current default behavior) + /// Prefer mV / kΩ / µF etc. from magnitude (default on, same as SCPI). + pub auto_scale_units: bool, +} + +impl Default for ModeDisplaySettings { + fn default() -> Self { + Self { + auto_scale_units: true, + } + } } /// We derive Deserialize/Serialize so we can persist app state on shutdown. #[derive(Serialize, Deserialize)] #[serde(default)] // if we add new fields, give them default values when deserializing old state pub struct MyApp { + connection_type: ConnectionType, serial_port: String, + #[cfg(not(target_arch = "wasm32"))] + hid_device_path: String, baud_rate: u32, bits: u32, stop_bits: u32, @@ -122,6 +192,9 @@ pub struct MyApp { readbuf: [u8; 1024], #[serde(skip)] portlist: VecDeque, + #[cfg(not(target_arch = "wasm32"))] + #[serde(skip)] + hid_devicelist: VecDeque<(String, String)>, #[serde(skip)] values: VecDeque, #[serde(skip)] @@ -155,11 +228,40 @@ pub struct MyApp { #[serde(skip)] shutdown_tx: Option>, // Signal to shutdown serial task #[serde(skip)] - mode_rx: Option>, // Channel for mode updates + mode_rx: Option>, // Channel for mode + unit updates + #[cfg(not(target_arch = "wasm32"))] + #[serde(skip)] + victor_86bcd_rx: Option>, + #[cfg(not(target_arch = "wasm32"))] + #[serde(skip)] + victor_lcd_display: String, #[serde(skip)] value_debug_shared: Arc>, // Shared debug flag for live updates #[serde(skip)] poll_interval_shared: Arc>, // Shared poll interval for live updates + #[cfg(not(target_arch = "wasm32"))] + #[serde(skip)] + victor_86bcd_capture_function: crate::victor_86bcd_capture::Victor86bcdCaptureFunction, + #[cfg(not(target_arch = "wasm32"))] + victor_86bcd_capture_unit: crate::victor_86bcd_capture::Victor86bcdCaptureUnit, + #[cfg(not(target_arch = "wasm32"))] + victor_86bcd_capture_dp_mode: crate::victor_86bcd_capture::Victor86bcdCaptureDpMode, + #[cfg(not(target_arch = "wasm32"))] + victor_86bcd_capture_digits: [crate::victor_86bcd_capture::LcdDigit; 4], + #[cfg(not(target_arch = "wasm32"))] + victor_86bcd_capture_dp_after: Option, + #[cfg(not(target_arch = "wasm32"))] + victor_86bcd_capture_notes: String, + #[cfg(not(target_arch = "wasm32"))] + victor_86bcd_capture_duration_ms: u64, + #[cfg(not(target_arch = "wasm32"))] + #[serde(skip)] + victor_86bcd_capture_tx: + Option>, + #[cfg(not(target_arch = "wasm32"))] + #[serde(skip)] + victor_86bcd_capture_status_shared: + Arc>, #[serde(skip)] last_graph_update: f64, // Track last graph update time #[serde(skip)] @@ -188,7 +290,10 @@ enum ConnectionState { impl Default for MyApp { fn default() -> Self { Self { + connection_type: ConnectionType::default(), serial_port: "".to_owned(), + #[cfg(not(target_arch = "wasm32"))] + hid_device_path: "".to_owned(), baud_rate: 115200, bits: 8, stop_bits: 1, @@ -210,6 +315,8 @@ impl Default for MyApp { issue_new_write: false, readbuf: [0u8; 1024], portlist: VecDeque::with_capacity(11), + #[cfg(not(target_arch = "wasm32"))] + hid_devicelist: VecDeque::with_capacity(4), values: VecDeque::with_capacity(MEM_DEPTH_DEFAULT + 1), hist_values: VecDeque::with_capacity(MEM_DEPTH_DEFAULT + 1), // Initialize histogram buffer poll: Poll::new().unwrap(), @@ -242,6 +349,10 @@ impl Default for MyApp { serial_tx: None, shutdown_tx: None, // Initially no shutdown signal mode_rx: None, // Initially no mode update channel + #[cfg(not(target_arch = "wasm32"))] + victor_86bcd_rx: None, + #[cfg(not(target_arch = "wasm32"))] + victor_lcd_display: String::new(), poll_interval_ms: 20, graph_update_interval_ms: 20, // Default to 20ms for ~50 FPS graph_update_interval_max: 1000, // Default maximum of 1000ms @@ -252,6 +363,29 @@ impl Default for MyApp { lock_remote: true, // Default to locking remote mode value_debug_shared: Arc::new(Mutex::new(false)), poll_interval_shared: Arc::new(Mutex::new(20)), + #[cfg(not(target_arch = "wasm32"))] + victor_86bcd_capture_function: + crate::victor_86bcd_capture::Victor86bcdCaptureFunction::default(), + #[cfg(not(target_arch = "wasm32"))] + victor_86bcd_capture_unit: crate::victor_86bcd_capture::Victor86bcdCaptureUnit::default( + ), + #[cfg(not(target_arch = "wasm32"))] + victor_86bcd_capture_dp_mode: + crate::victor_86bcd_capture::Victor86bcdCaptureDpMode::default(), + #[cfg(not(target_arch = "wasm32"))] + victor_86bcd_capture_digits: [crate::victor_86bcd_capture::LcdDigit::Off; 4], + #[cfg(not(target_arch = "wasm32"))] + victor_86bcd_capture_dp_after: None, + #[cfg(not(target_arch = "wasm32"))] + victor_86bcd_capture_notes: String::new(), + #[cfg(not(target_arch = "wasm32"))] + victor_86bcd_capture_duration_ms: 1000, + #[cfg(not(target_arch = "wasm32"))] + victor_86bcd_capture_tx: None, + #[cfg(not(target_arch = "wasm32"))] + victor_86bcd_capture_status_shared: Arc::new(Mutex::new( + crate::victor_86bcd_capture::Victor86bcdCaptureStatus::default(), + )), last_graph_update: 0.0, // Initialize to 0 last_hist_collect_time: 0.0, // Initialize to 0 connection_state: ConnectionState::Disconnected, // Initially disconnected @@ -380,6 +514,12 @@ impl MyApp { self.serial_tx = None; // Drop sender to stop sending commands self.serial_rx = None; // Drop receiver to stop receiving measurements self.mode_rx = None; // Drop mode receiver + #[cfg(not(target_arch = "wasm32"))] + { + self.victor_86bcd_rx = None; + self.victor_lcd_display.clear(); + self.victor_86bcd_capture_tx = None; + } self.serial = None; // Clear serial port self.connection_state = ConnectionState::Disconnected; self.connection_error = None; // Clear any previous error @@ -404,4 +544,57 @@ impl MyApp { .auto_scale_units = enabled; // Optional: self.save_settings() if you have an immediate-save helper } + + pub fn is_read_only(&self) -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + matches!( + self.connection_type, + ConnectionType::VictorHid + | ConnectionType::Victor86bcdSerial + | ConnectionType::Victor86eSerial + ) + } + #[cfg(target_arch = "wasm32")] + { + false + } + } + + #[cfg(not(target_arch = "wasm32"))] + fn is_victor_connection(&self) -> bool { + matches!( + self.connection_type, + ConnectionType::VictorHid + | ConnectionType::Victor86bcdSerial + | ConnectionType::Victor86eSerial + ) + } + + /// Whether a mode button should appear in the control panel for the current connection. + pub fn mode_visible_in_ui(&self, mode: MeterMode) -> bool { + match mode { + MeterMode::Duty => { + #[cfg(not(target_arch = "wasm32"))] + { + self.is_victor_connection() + } + #[cfg(target_arch = "wasm32")] + { + false + } + } + MeterMode::Per => { + #[cfg(not(target_arch = "wasm32"))] + { + self.connection_type == ConnectionType::ScpiSerial + } + #[cfg(target_arch = "wasm32")] + { + true + } + } + _ => true, + } + } } diff --git a/src/app/serial.rs b/src/app/serial.rs index b99220d..2286703 100644 --- a/src/app/serial.rs +++ b/src/app/serial.rs @@ -11,6 +11,23 @@ use crate::multimeter::{MeterMode, RateCmd, ScpiMode}; const SERIAL_TOKEN: Token = Token(0); +fn default_unit_for_mode(mode: MeterMode) -> String { + match mode { + MeterMode::Vdc => "VDC".to_owned(), + MeterMode::Vac => "VAC".to_owned(), + MeterMode::Adc => "ADC".to_owned(), + MeterMode::Aac => "AAC".to_owned(), + MeterMode::Res => "Ohm".to_owned(), + MeterMode::Cap => "F".to_owned(), + MeterMode::Freq => "Hz".to_owned(), + MeterMode::Per => "s".to_owned(), + MeterMode::Duty => "%".to_owned(), + MeterMode::Diod => "V".to_owned(), + MeterMode::Cont => "Ohm".to_owned(), + MeterMode::Temp => "°C".to_owned(), + } +} + impl super::MyApp { pub fn spawn_serial_task(&mut self) { if self.serial.is_none() { @@ -19,7 +36,7 @@ impl super::MyApp { let (tx_data, rx_data) = mpsc::channel::>(100); // Channel for measurements let (tx_cmd, mut rx_cmd) = mpsc::channel::(100); // Channel for commands - let (tx_mode, rx_mode) = mpsc::channel::(10); // Channel for mode updates + let (tx_mode, rx_mode) = mpsc::channel::<(MeterMode, String)>(10); let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>(); // Shutdown signal self.serial_rx = Some(rx_data); self.serial_tx = Some(tx_cmd.clone()); @@ -257,7 +274,8 @@ impl super::MyApp { }; if mode != last_mode { last_mode = mode; - let _ = tx_mode.send(mode).await; + let unit = default_unit_for_mode(mode); + let _ = tx_mode.send((mode, unit)).await; if mode == MeterMode::Cont { if beeper_enabled { command_queue.push_back("SYST:BEEP:STATe ON\n".to_string()); diff --git a/src/app/settings.rs b/src/app/settings.rs index 73a8150..619c9dd 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -27,6 +27,7 @@ impl super::MyApp { self.value_debug = value_debug; *self.value_debug_shared.lock().unwrap() = value_debug; } + ui.label("Baud rate:"); ui.add( TextEdit::singleline(&mut self.baud_rate.to_string()) diff --git a/src/app/ui.rs b/src/app/ui.rs index 2c6b15b..9bce588 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -76,6 +76,51 @@ impl TabViewer for PlotTabViewer<'_> { } impl super::MyApp { + /// Local CONT/DIOD threshold sliders. When `send_to_meter` is true, changes are sent via SCPI. + fn show_cont_diod_threshold_sliders(&mut self, ui: &mut egui::Ui, send_to_meter: bool) { + if self.metermode == MeterMode::Cont { + let threshold_slider = ui.add( + egui::Slider::new(&mut self.cont_threshold, 0..=1000) + .text("Threshold (Ω)") + .step_by(1.0) + .clamping(SliderClamping::Always), + ); + if send_to_meter && (threshold_slider.drag_stopped() || threshold_slider.lost_focus()) { + if let Some(tx) = self.serial_tx.clone() { + let cmd = format!("CONT:THREshold {}\n", self.cont_threshold); + let value_debug = self.value_debug; + tokio::spawn(async move { + if let Err(e) = tx.send(cmd).await { + if value_debug { + println!("Failed to queue threshold command: {}", e); + } + } + }); + } + } + } else if self.metermode == MeterMode::Diod { + let threshold_slider = ui.add( + egui::Slider::new(&mut self.diod_threshold, 0.0..=3.0) + .text("Threshold (V)") + .step_by(0.1) + .clamping(SliderClamping::Always), + ); + if send_to_meter && (threshold_slider.drag_stopped() || threshold_slider.lost_focus()) { + if let Some(tx) = self.serial_tx.clone() { + let cmd = format!("DIOD:THREshold {}\n", self.diod_threshold); + let value_debug = self.value_debug; + tokio::spawn(async move { + if let Err(e) = tx.send(cmd).await { + if value_debug { + println!("Failed to queue threshold command: {}", e); + } + } + }); + } + } + } + } + /// Called by the framework to save state before shutdown. pub fn save(&mut self, storage: &mut dyn eframe::Storage) { // Save recording data if recording is active @@ -97,6 +142,8 @@ impl super::MyApp { self.portlist.push_front(p.port_name); } } + #[cfg(not(target_arch = "wasm32"))] + self.refresh_hid_devices(); // Apply initial sampling rate self.confstring = self .ratecmd @@ -121,72 +168,78 @@ impl super::MyApp { self.is_init = true; } - // Process all available measurements - if let Some(ref mut rx) = self.serial_rx { - while let Ok(meas_opt) = rx.try_recv() { - if let Some(meas) = meas_opt { - self.curr_meas = meas; // Update curr_meas with new data + // Victor 86B/C/D serial: LCD text + mode from DM1107 frames (read-only, no SCPI ranges) + #[cfg(not(target_arch = "wasm32"))] + if self.connection_type == super::ConnectionType::Victor86bcdSerial { + if let Some(rx) = &mut self.victor_86bcd_rx { + while let Ok(update) = rx.try_recv() { + self.victor_lcd_display = update.display; + if let Some(v) = update.value { + self.curr_meas = v; + } + if update.mode != self.metermode { + self.metermode = update.mode; + self.curr_unit = update.unit; + self.values = VecDeque::with_capacity(self.mem_depth); + self.hist_values = VecDeque::with_capacity(self.hist_mem_depth); + self.rangecmd = None; + self.curr_range = 0; + if self.value_debug { + println!("Updated metermode to: {:?}", update.mode); + } + } else if self.curr_unit != update.unit { + self.curr_unit = update.unit; + } } } } - // Process all available mode updates + // SCPI / Victor 86E / HID — all use serial_rx for the live value + #[cfg(not(target_arch = "wasm32"))] + let poll_serial_rx = self.connection_type != super::ConnectionType::Victor86bcdSerial; + #[cfg(target_arch = "wasm32")] + let poll_serial_rx = true; + + if poll_serial_rx { + if let Some(ref mut rx) = self.serial_rx { + while let Ok(meas_opt) = rx.try_recv() { + if let Some(meas) = meas_opt { + self.curr_meas = meas; + } + } + } + } + + // Process mode updates (SCPI / Victor 86E / HID) + let read_only = self.is_read_only(); if let Some(ref mut rx) = self.mode_rx { - while let Ok(mode) = rx.try_recv() { + while let Ok((mode, unit)) = rx.try_recv() { if mode != self.metermode { self.metermode = mode; + self.curr_unit = unit; self.values = VecDeque::with_capacity(self.mem_depth); self.hist_values = VecDeque::with_capacity(self.hist_mem_depth); // Reset histogram buffer - match mode { - MeterMode::Vdc => { - self.curr_unit = "VDC".to_owned(); - self.rangecmd = RangeCmd::new(&self.curr_meter, "VDC"); - } - MeterMode::Vac => { - self.curr_unit = "VAC".to_owned(); - self.rangecmd = RangeCmd::new(&self.curr_meter, "VAC"); - } - MeterMode::Adc => { - self.curr_unit = "ADC".to_owned(); - self.rangecmd = RangeCmd::new(&self.curr_meter, "ADC"); - } - MeterMode::Aac => { - self.curr_unit = "AAC".to_owned(); - self.rangecmd = RangeCmd::new(&self.curr_meter, "AAC"); - } - MeterMode::Res => { - self.curr_unit = "Ohm".to_owned(); - self.rangecmd = RangeCmd::new(&self.curr_meter, "RES"); - } - MeterMode::Cap => { - self.curr_unit = "F".to_owned(); - self.rangecmd = RangeCmd::new(&self.curr_meter, "CAP"); - } - MeterMode::Freq => { - self.curr_unit = "Hz".to_owned(); - self.rangecmd = None; + self.rangecmd = if read_only { + None + } else { + match mode { + MeterMode::Vdc => RangeCmd::new(&self.curr_meter, "VDC"), + MeterMode::Vac => RangeCmd::new(&self.curr_meter, "VAC"), + MeterMode::Adc => RangeCmd::new(&self.curr_meter, "ADC"), + MeterMode::Aac => RangeCmd::new(&self.curr_meter, "AAC"), + MeterMode::Res => RangeCmd::new(&self.curr_meter, "RES"), + MeterMode::Cap => RangeCmd::new(&self.curr_meter, "CAP"), + MeterMode::Temp => RangeCmd::new(&self.curr_meter, "TEMP"), + _ => None, } - MeterMode::Per => { - self.curr_unit = "s".to_owned(); - self.rangecmd = None; - } - MeterMode::Diod => { - self.curr_unit = "V".to_owned(); - self.rangecmd = None; - } - MeterMode::Cont => { - self.curr_unit = "Ohm".to_owned(); - self.rangecmd = None; - } - MeterMode::Temp => { - self.curr_unit = "°C".to_owned(); - self.rangecmd = RangeCmd::new(&self.curr_meter, "TEMP"); - } - } + }; self.curr_range = 0; if self.value_debug { println!("Updated metermode to: {:?}", mode); } + } else if unit != self.curr_unit { + // Same mode, unit only (°C↔°F, or Ω↔kΩ on range change) + self.curr_unit = unit; } } } @@ -195,7 +248,8 @@ impl super::MyApp { let current_time = ui.ctx().input(|i| i.time); // Get current time in seconds let graph_interval = self.graph_update_interval_ms as f64 / 1000.0; // Convert ms to seconds if current_time - self.last_graph_update >= graph_interval { - if !self.curr_meas.is_nan() { + // Skip non-finite samples — histogram binning panics on Inf/NaN. + if self.curr_meas.is_finite() { self.values.push_back(self.curr_meas); self.update_histogram(self.curr_meas); // Update histogram with new measurement while self.values.len() > self.mem_depth { @@ -251,18 +305,110 @@ impl super::MyApp { ui.vertical(|ui| { ui.horizontal(|ui| { - ui.label("Serial port: "); - ui.add( - DropDownBox::from_iter( - &self.portlist, - "portlistbox", - &mut self.serial_port, - |ui, text| ui.selectable_label(false, text), - ) - .desired_width(150.0) - .select_on_focus(true) - .filter_by_input(false), - ); + #[cfg(not(target_arch = "wasm32"))] + { + ui.label("Connection:"); + egui::ComboBox::from_id_salt("connection_type") + .selected_text(match self.connection_type { + super::ConnectionType::ScpiSerial => "SCPI Serial (OWON)", + super::ConnectionType::VictorHid => "Victor USB HID (86B/C/D)", + super::ConnectionType::Victor86bcdSerial => { + "Victor Serial (86B/C/D)" + } + super::ConnectionType::Victor86eSerial => "Victor Serial (86E)", + }) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.connection_type, + super::ConnectionType::ScpiSerial, + "SCPI Serial (OWON)", + ); + ui.selectable_value( + &mut self.connection_type, + super::ConnectionType::VictorHid, + "Victor USB HID (86B/C/D)", + ); + ui.selectable_value( + &mut self.connection_type, + super::ConnectionType::Victor86bcdSerial, + "Victor Serial (86B/C/D)", + ); + ui.selectable_value( + &mut self.connection_type, + super::ConnectionType::Victor86eSerial, + "Victor Serial (86E)", + ); + }); + } + + match self.connection_type { + #[cfg(target_arch = "wasm32")] + super::ConnectionType::ScpiSerial => { + ui.label("Serial port:"); + ui.add( + DropDownBox::from_iter( + &self.portlist, + "portlistbox", + &mut self.serial_port, + |ui, text| ui.selectable_label(false, text), + ) + .desired_width(150.0) + .select_on_focus(true) + .filter_by_input(false), + ); + } + #[cfg(not(target_arch = "wasm32"))] + super::ConnectionType::ScpiSerial + | super::ConnectionType::Victor86bcdSerial + | super::ConnectionType::Victor86eSerial => { + ui.label("Serial port:"); + ui.add( + DropDownBox::from_iter( + &self.portlist, + "portlistbox", + &mut self.serial_port, + |ui, text| ui.selectable_label(false, text), + ) + .desired_width(150.0) + .select_on_focus(true) + .filter_by_input(false), + ); + } + #[cfg(not(target_arch = "wasm32"))] + super::ConnectionType::VictorHid => { + ui.label("HID device:"); + let mut selected_idx = self + .hid_devicelist + .iter() + .position(|(path, _)| path == &self.hid_device_path) + .unwrap_or(0); + egui::ComboBox::from_id_salt("hid_devicelist") + .selected_text( + self.hid_devicelist + .get(selected_idx) + .map(|(_, l)| l.as_str()) + .unwrap_or("No Victor device found"), + ) + .show_ui(ui, |ui| { + for (idx, (path, label)) in + self.hid_devicelist.iter().enumerate() + { + if ui + .selectable_value(&mut selected_idx, idx, label) + .clicked() + { + self.hid_device_path = path.clone(); + } + } + }); + if let Some((path, _)) = self.hid_devicelist.get(selected_idx) { + self.hid_device_path = path.clone(); + } + if ui.button("Refresh").clicked() { + self.refresh_hid_devices(); + } + } + } match self.connection_state { super::ConnectionState::Disconnected => { @@ -270,25 +416,93 @@ impl super::MyApp { connect_now = false; self.connection_state = super::ConnectionState::Connecting; self.connection_error = None; - match mio_serial::new(&self.serial_port, self.baud_rate) - .open_native_async() - { - Ok(serial) => { - self.serial = Some(serial); - if let Some(ref mut serial) = self.serial { - let _ = serial.set_data_bits(DataBits::Eight); - let _ = serial.set_stop_bits(mio_serial::StopBits::One); - let _ = serial.set_parity(mio_serial::Parity::None); + + match self.connection_type { + super::ConnectionType::ScpiSerial => { + match mio_serial::new(&self.serial_port, self.baud_rate) + .open_native_async() + { + Ok(serial) => { + self.serial = Some(serial); + if let Some(ref mut serial) = self.serial { + let _ = serial.set_data_bits(DataBits::Eight); + let _ = serial + .set_stop_bits(mio_serial::StopBits::One); + let _ = + serial.set_parity(mio_serial::Parity::None); + self.connection_state = + super::ConnectionState::Connected; + self.curr_meter = "OWON XDM1041".to_owned(); + self.spawn_serial_task(); + } + } + Err(e) => { + self.connection_state = + super::ConnectionState::Disconnected; + self.connection_error = + Some(format!("Failed to connect: {}", e)); + } + } + } + #[cfg(not(target_arch = "wasm32"))] + super::ConnectionType::VictorHid => { + if self.hid_device_path.is_empty() { + self.connection_state = + super::ConnectionState::Disconnected; + self.connection_error = + Some("No Victor HID device selected".to_owned()); + } else { self.connection_state = super::ConnectionState::Connected; - self.spawn_serial_task(); + self.curr_meter = "Victor 86B/C/D".to_owned(); + self.rangecmd = None; + self.spawn_hid_task(); } } - Err(e) => { - self.connection_state = - super::ConnectionState::Disconnected; - self.connection_error = - Some(format!("Failed to connect: {}", e)); + #[cfg(not(target_arch = "wasm32"))] + super::ConnectionType::Victor86bcdSerial => { + match super::open_victor_8n1_serial( + &self.serial_port, + crate::victor_dm1107::VICTOR_86BCD_BAUD, + ) { + Ok(serial) => { + self.serial = Some(serial); + self.connection_state = + super::ConnectionState::Connected; + self.curr_meter = + "Victor 86B/C/D (DM1107)".to_owned(); + self.rangecmd = None; + self.spawn_victor_86bcd_serial_task(); + } + Err(e) => { + self.connection_state = + super::ConnectionState::Disconnected; + self.connection_error = + Some(format!("Failed to connect: {}", e)); + } + } + } + #[cfg(not(target_arch = "wasm32"))] + super::ConnectionType::Victor86eSerial => { + match super::open_victor_7o1_serial( + &self.serial_port, + crate::victor_es519xx::VICTOR_86E_BAUD, + ) { + Ok(serial) => { + self.serial = Some(serial); + self.connection_state = + super::ConnectionState::Connected; + self.curr_meter = "Victor 86E".to_owned(); + self.rangecmd = None; + self.spawn_victor_86e_serial_task(); + } + Err(e) => { + self.connection_state = + super::ConnectionState::Disconnected; + self.connection_error = + Some(format!("Failed to connect: {}", e)); + } + } } } } @@ -336,17 +550,33 @@ impl super::MyApp { ui.separator(); + #[cfg(not(target_arch = "wasm32"))] + if self.value_debug + && self.connection_type == super::ConnectionType::Victor86bcdSerial + && self.connection_state == super::ConnectionState::Connected + { + let capture_frame = egui::Frame { + inner_margin: 12.0.into(), + outer_margin: egui::Margin::symmetric(24, 8), + corner_radius: 5.0.into(), + shadow: epaint::Shadow::NONE, + fill: self.box_background_color, + stroke: egui::Stroke::new(1.0, egui::Color32::DARK_GRAY), + }; + capture_frame.show(ui, |ui| { + self.show_victor_86bcd_capture_panel(ui); + }); + } + ui.horizontal(|ui| { // Determine if the background and shadow should be dark red based on mode and threshold let is_below_threshold = match self.metermode { - MeterMode::Cont => self - .values - .back() - .is_some_and(|&val| val <= self.cont_threshold as f64), - MeterMode::Diod => self - .values - .back() - .is_some_and(|&val| val <= self.diod_threshold as f64), + MeterMode::Cont => { + self.curr_meas.is_finite() && self.curr_meas <= self.cont_threshold as f64 + } + MeterMode::Diod => { + self.curr_meas.is_finite() && self.curr_meas <= self.diod_threshold as f64 + } _ => false, }; let background_color = if is_below_threshold { @@ -380,6 +610,72 @@ impl super::MyApp { Vec2 { x: 400.0, y: 300.0 }, egui::Layout::top_down(egui::Align::RIGHT).with_cross_justify(false), |ui| { + #[cfg(not(target_arch = "wasm32"))] + let (formatted_value, display_unit) = { + // 86B/C/D only: glass text from segment decode. + let lcd_override = if self.connection_type + == super::ConnectionType::Victor86bcdSerial + && self.curr_meas != crate::helpers::METER_OVERLOAD_VALUE + && !self.victor_lcd_display.is_empty() + { + Some(( + self.victor_lcd_display.as_str(), + self.curr_unit.as_str(), + )) + } else { + None + }; + // 86B/C/D: lcd_override. HID: no auto-scale. + // SCPI: format_measurement(auto_scale). + // 86E: ON → SI + magnitude auto; OFF → decoder unit (meter range). + let auto_scale = match self.connection_type { + super::ConnectionType::Victor86bcdSerial + | super::ConnectionType::VictorHid => false, + _ => self.auto_scale_units(&self.metermode), + }; + let (formatted_value, mut display_unit) = { + let use_meter_unit = self.connection_type + == super::ConnectionType::Victor86eSerial + && !auto_scale + && !self.curr_unit.is_empty() + && self.curr_meas.is_finite() + && self.curr_meas != crate::helpers::METER_OVERLOAD_VALUE; + + if use_meter_unit { + // What the meter “sends” as unit for this range. + let scaled = crate::victor_es519xx::si_to_meter_unit( + self.curr_meas, + &self.curr_unit, + ); + let (num, _) = format_measurement( + scaled, + 10, + 1_000_000.0, + 0.000001, + &self.metermode, + false, + None, + ); + (num, self.curr_unit.clone()) + } else { + format_measurement( + self.curr_meas, + 10, + 1_000_000.0, + 0.000001, + &self.metermode, + auto_scale, + lcd_override, + ) + } + }; + // Temp unit from decoder (°C / °F); formatter defaults to °C. + if self.metermode == MeterMode::Temp && !self.curr_unit.is_empty() { + display_unit = self.curr_unit.clone(); + } + (formatted_value, display_unit) + }; + #[cfg(target_arch = "wasm32")] let (formatted_value, display_unit) = format_measurement( self.curr_meas, 10, @@ -387,6 +683,7 @@ impl super::MyApp { 0.000001, &self.metermode, self.auto_scale_units(&self.metermode), + None, ); ui.label( egui::RichText::new(formatted_value) @@ -423,139 +720,164 @@ impl super::MyApp { }; control_frame.show(ui, |ui| { ui.vertical(|ui| { + if self.is_read_only() { + ui.label( + egui::RichText::new("Read only — change mode on the meter") + .italics(), + ); + } let btn_size = Vec2 { x: 70.0, y: 20.0 }; - ui.horizontal(|ui| { - let vdc_btn = egui::Button::new("VDC") - .selected(self.metermode == MeterMode::Vdc) - .min_size(btn_size); - if ui.add(vdc_btn).clicked() { - self.set_mode( - MeterMode::Vdc, - "VDC", - "CONF:VOLT:DC AUTO\n", - Some("VDC"), - None, - ); - } - let vac_btn = egui::Button::new("VAC") - .selected(self.metermode == MeterMode::Vac) - .min_size(btn_size); - if ui.add(vac_btn).clicked() { - self.set_mode( - MeterMode::Vac, - "VAC", - "CONF:VOLT:AC AUTO\n", - Some("VAC"), - None, - ); - } - let adc_btn = egui::Button::new("ADC") - .selected(self.metermode == MeterMode::Adc) - .min_size(btn_size); - if ui.add(adc_btn).clicked() { - self.set_mode( - MeterMode::Adc, - "ADC", - "CONF:CURR:DC AUTO\n", - Some("ADC"), - None, - ); - } - let aac_btn = egui::Button::new("AAC") - .selected(self.metermode == MeterMode::Aac) - .min_size(btn_size); - if ui.add(aac_btn).clicked() { - self.set_mode( - MeterMode::Aac, - "AAC", - "CONF:CURR:AC AUTO\n", - Some("AAC"), - None, - ); - } - }); - ui.horizontal(|ui| { - let res_btn = egui::Button::new("Ohm") - .selected(self.metermode == MeterMode::Res) - .min_size(btn_size); - if ui.add(res_btn).clicked() { - self.set_mode( - MeterMode::Res, - "Ohm", - "CONF:RES AUTO\n", - Some("RES"), - None, - ); - } - let cap_btn = egui::Button::new("C") - .selected(self.metermode == MeterMode::Cap) - .min_size(btn_size); - if ui.add(cap_btn).clicked() { - self.set_mode( - MeterMode::Cap, - "F", - "CONF:CAP AUTO\n", - Some("CAP"), - None, - ); - } - let freq_btn = egui::Button::new("Freq") - .selected(self.metermode == MeterMode::Freq) - .min_size(btn_size); - if ui.add(freq_btn).clicked() { - self.set_mode( - MeterMode::Freq, - "Hz", - "CONF:FREQ\n", - Some("FREQ"), - None, - ); - } - let per_btn = egui::Button::new("Period") - .selected(self.metermode == MeterMode::Per) - .min_size(btn_size); - if ui.add(per_btn).clicked() { - self.set_mode(MeterMode::Per, "s", "CONF:PER\n", Some("PER"), None); - } - }); - ui.horizontal(|ui| { - let diod_btn = egui::Button::new("Diode") - .selected(self.metermode == MeterMode::Diod) - .min_size(btn_size); - if ui.add(diod_btn).clicked() { - self.set_mode( - MeterMode::Diod, - "V", - "CONF:DIOD\n", - Some("DIOD"), - Some(self.beeper_enabled), - ); - } - let cont_btn = egui::Button::new("Cont") - .selected(self.metermode == MeterMode::Cont) - .min_size(btn_size); - if ui.add(cont_btn).clicked() { - self.set_mode( - MeterMode::Cont, - "Ohm", - "CONF:CONT\n", - Some("CONT"), - Some(self.beeper_enabled), - ); - } - let temp_btn = egui::Button::new("Temp") - .selected(self.metermode == MeterMode::Temp) - .min_size(btn_size); - if ui.add(temp_btn).clicked() { - self.set_mode( - MeterMode::Temp, - "°C", - "CONF:TEMP:RTD PT100\n", - Some("TEMP"), - None, - ); - } - }); + let read_only = self.is_read_only(); + ui.add_enabled_ui(!read_only, |ui| { + ui.horizontal(|ui| { + let vdc_btn = egui::Button::new("VDC") + .selected(self.metermode == MeterMode::Vdc) + .min_size(btn_size); + if ui.add(vdc_btn).clicked() { + self.set_mode( + MeterMode::Vdc, + "VDC", + "CONF:VOLT:DC AUTO\n", + Some("VDC"), + None, + ); + } + let vac_btn = egui::Button::new("VAC") + .selected(self.metermode == MeterMode::Vac) + .min_size(btn_size); + if ui.add(vac_btn).clicked() { + self.set_mode( + MeterMode::Vac, + "VAC", + "CONF:VOLT:AC AUTO\n", + Some("VAC"), + None, + ); + } + let adc_btn = egui::Button::new("ADC") + .selected(self.metermode == MeterMode::Adc) + .min_size(btn_size); + if ui.add(adc_btn).clicked() { + self.set_mode( + MeterMode::Adc, + "ADC", + "CONF:CURR:DC AUTO\n", + Some("ADC"), + None, + ); + } + let aac_btn = egui::Button::new("AAC") + .selected(self.metermode == MeterMode::Aac) + .min_size(btn_size); + if ui.add(aac_btn).clicked() { + self.set_mode( + MeterMode::Aac, + "AAC", + "CONF:CURR:AC AUTO\n", + Some("AAC"), + None, + ); + } + }); + ui.horizontal(|ui| { + let res_btn = egui::Button::new("Ohm") + .selected(self.metermode == MeterMode::Res) + .min_size(btn_size); + if ui.add(res_btn).clicked() { + self.set_mode( + MeterMode::Res, + "Ohm", + "CONF:RES AUTO\n", + Some("RES"), + None, + ); + } + let cap_btn = egui::Button::new("C") + .selected(self.metermode == MeterMode::Cap) + .min_size(btn_size); + if ui.add(cap_btn).clicked() { + self.set_mode( + MeterMode::Cap, + "F", + "CONF:CAP AUTO\n", + Some("CAP"), + None, + ); + } + let freq_btn = egui::Button::new("Freq") + .selected(self.metermode == MeterMode::Freq) + .min_size(btn_size); + if ui.add(freq_btn).clicked() { + self.set_mode( + MeterMode::Freq, + "Hz", + "CONF:FREQ\n", + Some("FREQ"), + None, + ); + } + if self.mode_visible_in_ui(MeterMode::Per) { + let per_btn = egui::Button::new("Period") + .selected(self.metermode == MeterMode::Per) + .min_size(btn_size); + if ui.add(per_btn).clicked() { + self.set_mode( + MeterMode::Per, + "s", + "CONF:PER\n", + Some("PER"), + None, + ); + } + } + if self.mode_visible_in_ui(MeterMode::Duty) { + let duty_btn = egui::Button::new("Duty") + .selected(self.metermode == MeterMode::Duty) + .min_size(btn_size); + if ui.add(duty_btn).clicked() { + self.set_mode(MeterMode::Duty, "%", "", None, None); + } + } + }); + ui.horizontal(|ui| { + let diod_btn = egui::Button::new("Diode") + .selected(self.metermode == MeterMode::Diod) + .min_size(btn_size); + if ui.add(diod_btn).clicked() { + self.set_mode( + MeterMode::Diod, + "V", + "CONF:DIOD\n", + Some("DIOD"), + Some(self.beeper_enabled), + ); + } + let cont_btn = egui::Button::new("Cont") + .selected(self.metermode == MeterMode::Cont) + .min_size(btn_size); + if ui.add(cont_btn).clicked() { + self.set_mode( + MeterMode::Cont, + "Ohm", + "CONF:CONT\n", + Some("CONT"), + Some(self.beeper_enabled), + ); + } + let temp_btn = egui::Button::new("Temp") + .selected(self.metermode == MeterMode::Temp) + .min_size(btn_size); + if ui.add(temp_btn).clicked() { + self.set_mode( + MeterMode::Temp, + "°C", + "CONF:TEMP:RTD PT100\n", + Some("TEMP"), + None, + ); + } + }); + }); // add_enabled_ui }); }); @@ -574,38 +896,20 @@ impl super::MyApp { }; options_frame.show(ui, |ui| { ui.vertical(|ui| { - let ratebox = egui::ComboBox::from_label("Sampling Rate").show_index( - ui, - &mut self.curr_rate, - self.ratecmd.len(), - |i| self.ratecmd.get_opt(i).0, - ); - if ratebox.changed() { - self.confstring = self - .ratecmd - .gen_scpi(self.ratecmd.get_opt(self.curr_rate).0); - if let Some(tx) = self.serial_tx.clone() { - let cmd = self.confstring.clone(); - tokio::spawn(async move { - if let Err(e) = tx.send(cmd).await { - println!("Failed to queue command: {}", e); - } - }); - } - if self.value_debug { - println!("Selected Rate changed: {}", self.confstring); - } - } - if let Some(rangecmd) = &self.rangecmd { - let rangebox = egui::ComboBox::from_label("Range").show_index( + if self.is_read_only() { + ui.label(egui::RichText::new("Range controlled on device").italics()); + self.show_cont_diod_threshold_sliders(ui, false); + } else { + let ratebox = egui::ComboBox::from_label("Sampling Rate").show_index( ui, - &mut self.curr_range, - rangecmd.len(), - |i| rangecmd.get_opt(i).0, + &mut self.curr_rate, + self.ratecmd.len(), + |i| self.ratecmd.get_opt(i).0, ); - if rangebox.changed() { - self.confstring = - rangecmd.gen_scpi(rangecmd.get_opt(self.curr_range).0); + if ratebox.changed() { + self.confstring = self + .ratecmd + .gen_scpi(self.ratecmd.get_opt(self.curr_rate).0); if let Some(tx) = self.serial_tx.clone() { let cmd = self.confstring.clone(); tokio::spawn(async move { @@ -615,75 +919,51 @@ impl super::MyApp { }); } if self.value_debug { - println!("Selected Range changed: {}", self.confstring); + println!("Selected Rate changed: {}", self.confstring); } } - } - // Add beeper and threshold controls for CONT and DIOD modes - if self.metermode == MeterMode::Cont || self.metermode == MeterMode::Diod { - let mut beeper = self.beeper_enabled; - if ui.checkbox(&mut beeper, "Beeper").changed() { - self.beeper_enabled = beeper; - if let Some(tx) = self.serial_tx.clone() { - let cmd = if beeper { - "SYST:BEEP:STATe ON\n".to_string() - } else { - "SYST:BEEP:STATe OFF\n".to_string() - }; - let value_debug = self.value_debug; - tokio::spawn(async move { - if let Err(e) = tx.send(cmd).await { - if value_debug { - println!("Failed to queue beeper command: {}", e); - } - } - }); - } - } - - if self.metermode == MeterMode::Cont { - let threshold_slider = ui.add( - egui::Slider::new(&mut self.cont_threshold, 0..=1000) - .text("Threshold (Ω)") - .step_by(1.0) - .clamping(SliderClamping::Always), + if let Some(rangecmd) = &self.rangecmd { + let rangebox = egui::ComboBox::from_label("Range").show_index( + ui, + &mut self.curr_range, + rangecmd.len(), + |i| rangecmd.get_opt(i).0, ); - if threshold_slider.drag_stopped() || threshold_slider.lost_focus() - { + if rangebox.changed() { + self.confstring = + rangecmd.gen_scpi(rangecmd.get_opt(self.curr_range).0); if let Some(tx) = self.serial_tx.clone() { - let cmd = - format!("CONT:THREshold {}\n", self.cont_threshold); - let value_debug = self.value_debug; + let cmd = self.confstring.clone(); tokio::spawn(async move { if let Err(e) = tx.send(cmd).await { - if value_debug { - println!( - "Failed to queue threshold command: {}", - e - ); - } + println!("Failed to queue command: {}", e); } }); } + if self.value_debug { + println!("Selected Range changed: {}", self.confstring); + } } - } else if self.metermode == MeterMode::Diod { - let threshold_slider = ui.add( - egui::Slider::new(&mut self.diod_threshold, 0.0..=3.0) - .text("Threshold (V)") - .step_by(0.1) - .clamping(SliderClamping::Always), - ); - if threshold_slider.drag_stopped() || threshold_slider.lost_focus() - { + } + // Beeper + thresholds for SCPI CONT/DIOD + if self.metermode == MeterMode::Cont + || self.metermode == MeterMode::Diod + { + let mut beeper = self.beeper_enabled; + if ui.checkbox(&mut beeper, "Beeper").changed() { + self.beeper_enabled = beeper; if let Some(tx) = self.serial_tx.clone() { - let cmd = - format!("DIOD:THREshold {}\n", self.diod_threshold); + let cmd = if beeper { + "SYST:BEEP:STATe ON\n".to_string() + } else { + "SYST:BEEP:STATe OFF\n".to_string() + }; let value_debug = self.value_debug; tokio::spawn(async move { if let Err(e) = tx.send(cmd).await { if value_debug { println!( - "Failed to queue threshold command: {}", + "Failed to queue beeper command: {}", e ); } @@ -691,19 +971,29 @@ impl super::MyApp { }); } } + self.show_cont_diod_threshold_sliders(ui, true); } } - // scale control per mode - let mut auto_scale = self.auto_scale_units(&self.metermode); - if ui - .checkbox(&mut auto_scale, "Auto-scale units") - .on_hover_text( - "Auto scale values and show prefixed units like mV/mΩ/kΩ", - ) - .changed() - { - self.set_auto_scale_units(self.metermode, auto_scale); + // SI-based meters (SCPI, 86E): same auto-scale control. + // LCD/HID Victors use fixed glass text / no magnitude auto-scale. + let show_auto_scale = match self.connection_type { + #[cfg(not(target_arch = "wasm32"))] + super::ConnectionType::Victor86bcdSerial + | super::ConnectionType::VictorHid => false, + _ => true, + }; + if show_auto_scale { + let mut auto_scale = self.auto_scale_units(&self.metermode); + if ui + .checkbox(&mut auto_scale, "Auto-scale units") + .on_hover_text( + "Auto scale values and show prefixed units like mV/mΩ/kΩ", + ) + .changed() + { + self.set_auto_scale_units(self.metermode, auto_scale); + } } }); }); diff --git a/src/app/victor_86bcd_capture_ui.rs b/src/app/victor_86bcd_capture_ui.rs new file mode 100644 index 0000000..ab5157e --- /dev/null +++ b/src/app/victor_86bcd_capture_ui.rs @@ -0,0 +1,178 @@ +//! Victor 86B/C/D serial labeled capture panel (DM1107). + +use egui::{DragValue, RichText}; + +use crate::victor_86bcd_capture::{ + LcdDigit, LcdDisplay, Victor86bcdCaptureContext, Victor86bcdCaptureDpMode, + Victor86bcdCaptureFunction, Victor86bcdCaptureJob, Victor86bcdCaptureUnit, +}; + +use super::MyApp; + +impl MyApp { + pub fn show_victor_86bcd_capture_panel(&mut self, ui: &mut egui::Ui) { + ui.heading("Victor 86B/C/D serial capture"); + ui.label( + "DM1107 USB-serial meters only (not 86E ES51932 or legacy HID). \ + Describe what the meter shows, then record a raw byte window (9600 8N1). \ + Set each LCD digit: off (_), 0–9, or L. Decimal-after picks where the point sits \ + between digits (manual range = fixed position).", + ); + + ui.horizontal(|ui| { + ui.label("Function:"); + let prev_function = self.victor_86bcd_capture_function; + egui::ComboBox::from_id_salt("victor_86bcd_capture_function") + .selected_text(self.victor_86bcd_capture_function.label()) + .show_ui(ui, |ui| { + for function in Victor86bcdCaptureFunction::all() { + ui.selectable_value( + &mut self.victor_86bcd_capture_function, + *function, + function.label(), + ); + } + }); + if self.victor_86bcd_capture_function != prev_function { + self.victor_86bcd_capture_unit = self.victor_86bcd_capture_function.default_unit(); + } + + ui.label("Unit:"); + egui::ComboBox::from_id_salt("victor_86bcd_capture_unit") + .selected_text(self.victor_86bcd_capture_unit.label()) + .show_ui(ui, |ui| { + for unit in Victor86bcdCaptureUnit::all() { + ui.selectable_value( + &mut self.victor_86bcd_capture_unit, + *unit, + unit.label(), + ); + } + }); + + ui.label("Decimal:"); + egui::ComboBox::from_id_salt("victor_86bcd_capture_dp_mode") + .selected_text(self.victor_86bcd_capture_dp_mode.label()) + .show_ui(ui, |ui| { + for mode in Victor86bcdCaptureDpMode::all() { + ui.selectable_value( + &mut self.victor_86bcd_capture_dp_mode, + *mode, + mode.label(), + ); + } + }); + }); + + ui.horizontal(|ui| { + ui.label("LCD:"); + for (i, digit) in self.victor_86bcd_capture_digits.iter_mut().enumerate() { + ui.label(format!("d{i}")); + egui::ComboBox::from_id_salt(format!("victor_86bcd_capture_d{i}")) + .selected_text(digit.ui_label()) + .width(52.0) + .show_ui(ui, |ui| { + for option in LcdDigit::all() { + ui.selectable_value(digit, *option, option.ui_label()); + } + }); + } + + ui.label("DP after:"); + let mut dp_choice = self + .victor_86bcd_capture_dp_after + .map(|n| n + 1) + .unwrap_or(0); + egui::ComboBox::from_id_salt("victor_86bcd_capture_dp_after") + .selected_text(match self.victor_86bcd_capture_dp_after { + None => "none", + Some(0) => "d0", + Some(1) => "d1", + Some(2) => "d2", + Some(_) => "?", + }) + .width(64.0) + .show_ui(ui, |ui| { + ui.selectable_value(&mut dp_choice, 0, "none"); + ui.selectable_value(&mut dp_choice, 1, "d0"); + ui.selectable_value(&mut dp_choice, 2, "d1"); + ui.selectable_value(&mut dp_choice, 3, "d2"); + }); + self.victor_86bcd_capture_dp_after = match dp_choice { + 0 => None, + n => Some(n - 1), + }; + + let preview = LcdDisplay { + digits: self.victor_86bcd_capture_digits, + dp_after: self.victor_86bcd_capture_dp_after, + }; + ui.label(RichText::new(format!("→ {}", preview.format())).monospace()); + }); + + ui.horizontal(|ui| { + ui.label("Notes:"); + ui.add( + egui::TextEdit::singleline(&mut self.victor_86bcd_capture_notes) + .desired_width(280.0) + .hint_text("REL, MAX/MIN — optional"), + ); + }); + + ui.horizontal(|ui| { + ui.label("Record duration:"); + ui.add( + DragValue::new(&mut self.victor_86bcd_capture_duration_ms) + .range(50..=10_000) + .speed(50) + .suffix(" ms"), + ); + ui.label("of raw serial (click to type)"); + }); + + let display = LcdDisplay { + digits: self.victor_86bcd_capture_digits, + dp_after: self.victor_86bcd_capture_dp_after, + }; + let can_capture = self.connection_state == super::ConnectionState::Connected + && self.victor_86bcd_capture_tx.is_some() + && !display.is_empty(); + + if ui + .add_enabled(can_capture, egui::Button::new("Capture sample")) + .on_hover_text("Append one CSV row: d0–d3, dp_after, and raw bytes in the window") + .clicked() + { + if let Some(tx) = &self.victor_86bcd_capture_tx { + let job = Victor86bcdCaptureJob { + context: Victor86bcdCaptureContext { + function: self.victor_86bcd_capture_function, + unit: self.victor_86bcd_capture_unit, + dp_mode: self.victor_86bcd_capture_dp_mode, + display, + notes: self.victor_86bcd_capture_notes.clone(), + }, + duration_ms: self.victor_86bcd_capture_duration_ms, + }; + if tx.try_send(job).is_err() { + let mut st = self.victor_86bcd_capture_status_shared.lock().unwrap(); + st.message = "Capture busy — wait for current recording".to_owned(); + } + } + } + + let status = self.victor_86bcd_capture_status_shared.lock().unwrap(); + if !status.message.is_empty() { + ui.add_space(4.0); + ui.label(RichText::new(&status.message).small()); + } + ui.label( + RichText::new(format!( + "CSV: {} | explore: cargo run --bin victor-86bcd-explore", + crate::victor_86bcd_capture::default_samples_path().display() + )) + .small() + .weak(), + ); + } +} diff --git a/src/app/victor_readonly_serial.rs b/src/app/victor_readonly_serial.rs new file mode 100644 index 0000000..58de458 --- /dev/null +++ b/src/app/victor_readonly_serial.rs @@ -0,0 +1,485 @@ +//! Read-only Victor serial task (86E ES51932 and 86B/C/D DM1107). +//! +//! Both meters stream measurement frames over CP2102 USB-UART; neither accepts +//! remote commands. Serial line settings are applied before spawn (see +//! [`super::open_victor_8n1_serial`] / [`super::open_victor_7o1_serial`]). +//! +//! I/O uses [`mio::Poll`] on a blocking thread. The poll wakes when the port is +//! readable; the idle timeout exists only so shutdown can be observed. + +use std::{ + io::{self, Read}, + path::PathBuf, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; + +use mio::{Events, Interest, Poll, Token}; +use mio_serial::{ClearBuffer, SerialPort}; +use tokio::sync::{mpsc, oneshot}; +use tokio::time::{Instant, sleep_until}; + +use crate::multimeter::MeterMode; +use crate::victor_86bcd_capture::{self, Victor86bcdCaptureJob}; +use crate::victor_dm1107::{self, Dm1107LiveUpdate}; +use crate::victor_es519xx; + +const SERIAL_TOKEN: Token = Token(2); +const IDLE_POLL_MS: u64 = 250; + +/// Which Victor read-only wire protocol to decode. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VictorReadonlyProtocol { + /// Victor 86E — Cyrustek ES51932, 19200 7o1, 14-byte ASCII frames. + Es519xx, + /// Victor 86B/C/D — DM1107, 9600 8N1, 20-byte `a5 12` binary frames. + Dm1107, +} + +impl VictorReadonlyProtocol { + fn device_label(self) -> &'static str { + match self { + Self::Es519xx => "Victor 86E (read only)", + Self::Dm1107 => "Victor 86B/C/D serial (DM1107)", + } + } + + fn startup_log(self) -> &'static str { + match self { + Self::Es519xx => "Victor 86E serial task started (ES51932, 19200 7o1)", + Self::Dm1107 => "Victor 86B/C/D serial task started (DM1107, 9600 8N1)", + } + } +} + +struct ActiveCapture { + job: Victor86bcdCaptureJob, + buf: Vec, + ends_at: Instant, +} + +struct CaptureSide { + rx: mpsc::Receiver, + status: Arc>, +} + +enum TaskDispatch { + Es519xx { + tx_value: mpsc::Sender>, + tx_mode: mpsc::Sender<(MeterMode, String)>, + }, + Dm1107 { + tx_live: mpsc::Sender, + }, +} + +enum Decoder { + Es519xx { + packet_buf: Vec, + last_mode: Option, + last_unit: Option, + }, + Dm1107 { + stream: victor_dm1107::Dm1107Stream, + }, +} + +impl Decoder { + fn new(protocol: VictorReadonlyProtocol) -> Self { + match protocol { + VictorReadonlyProtocol::Es519xx => Self::Es519xx { + packet_buf: Vec::with_capacity(512), + last_mode: None, + last_unit: None, + }, + VictorReadonlyProtocol::Dm1107 => Self::Dm1107 { + stream: victor_dm1107::Dm1107Stream::new(), + }, + } + } + + async fn feed_and_dispatch( + &mut self, + protocol: VictorReadonlyProtocol, + chunk: &[u8], + capture: &mut Option, + dispatch: &TaskDispatch, + debug: bool, + ) { + if debug { + match protocol { + VictorReadonlyProtocol::Es519xx => { + println!("Victor 86E +{} bytes", chunk.len()); + } + VictorReadonlyProtocol::Dm1107 => { + println!( + "Victor serial +{} bytes: {}", + chunk.len(), + victor_dm1107::hex_encode(&chunk[..chunk.len().min(32)]), + ); + } + } + } + + if let Some(cap) = capture { + cap.buf.extend_from_slice(chunk); + } + + match (self, dispatch) { + ( + Self::Es519xx { + packet_buf, + last_mode, + last_unit, + }, + TaskDispatch::Es519xx { tx_value, tx_mode }, + ) => { + for reading in victor_es519xx::feed_bytes(packet_buf, chunk) { + if debug { + println!( + "Victor 86E reading: {} {:?} {}", + reading.value, reading.mode, reading.unit + ); + } + let _ = tx_value.send(Some(reading.value)).await; + // Always push mode/unit so range-unit changes (Ω↔kΩ) update the UI. + let mode_changed = *last_mode != Some(reading.mode); + let unit_changed = last_unit.as_deref() != Some(reading.unit.as_str()); + if mode_changed || unit_changed { + *last_mode = Some(reading.mode); + *last_unit = Some(reading.unit.clone()); + } + // Send every sample: cheap, keeps curr_unit in sync with the wire range. + let _ = tx_mode.send((reading.mode, reading.unit)).await; + } + } + (Self::Dm1107 { stream }, TaskDispatch::Dm1107 { tx_live }) => { + for update in victor_dm1107::feed_bytes(stream, chunk) { + if debug { + println!( + "Victor 86B/C/D: {} {} {:?}", + update.display, update.unit, update.mode + ); + } + let _ = tx_live.send(update).await; + } + } + _ => {} + } + } +} + +fn write_capture_status( + status: &Arc>, + msg: String, + bytes: usize, +) { + let mut st = status.lock().unwrap(); + st.message = msg; + st.bytes_written = bytes; +} + +fn finish_labeled_capture(cap: ActiveCapture, debug: bool, status: &CaptureSide) { + let path: PathBuf = victor_86bcd_capture::default_samples_path(); + let job = cap.job; + match victor_86bcd_capture::append_labeled_capture( + &path, + &job.context, + job.duration_ms, + &cap.buf, + ) { + Ok(written) => { + let msg = format!( + "Saved {written} bytes ({} ms) → {}", + job.duration_ms, + path.display() + ); + if debug { + println!("{msg}"); + if written > 0 { + let preview = victor_dm1107::hex_encode(&cap.buf[..written.min(48)]); + println!(" start: {preview}…"); + } + } + write_capture_status(&status.status, msg, written); + } + Err(e) => { + let msg = format!("Capture write failed: {e}"); + eprintln!("{msg}"); + write_capture_status(&status.status, msg, 0); + } + } +} + +fn drain_readable_serial( + serial: &mut mio_serial::SerialStream, + readbuf: &mut [u8], +) -> io::Result> { + let mut chunk = Vec::new(); + loop { + match serial.read(readbuf) { + Ok(0) => break, + Ok(count) => chunk.extend_from_slice(&readbuf[..count]), + Err(e) if e.kind() == io::ErrorKind::WouldBlock => break, + Err(e) => return Err(e), + } + } + Ok(chunk) +} + +struct SerialReader { + bytes_rx: mpsc::Receiver>, + reader_handle: tokio::task::JoinHandle<()>, + stop: Arc, +} + +impl SerialReader { + fn spawn(mut serial: mio_serial::SerialStream) -> Self { + let (bytes_tx, bytes_rx) = mpsc::channel::>(32); + let stop = Arc::new(AtomicBool::new(false)); + let stop_bg = stop.clone(); + + let reader_handle = tokio::task::spawn_blocking(move || { + let mut poll = match Poll::new() { + Ok(p) => p, + Err(e) => { + eprintln!("Victor serial Poll::new failed: {e}"); + return; + } + }; + let mut events = Events::with_capacity(1); + if let Err(e) = poll + .registry() + .register(&mut serial, SERIAL_TOKEN, Interest::READABLE) + { + eprintln!("Victor serial register failed: {e}"); + return; + } + + let mut readbuf = [0u8; 256]; + loop { + if stop_bg.load(Ordering::Acquire) { + break; + } + + match poll.poll(&mut events, Some(Duration::from_millis(IDLE_POLL_MS))) { + Ok(()) => { + for event in events.iter() { + if !event.is_readable() { + continue; + } + match drain_readable_serial(&mut serial, &mut readbuf) { + Ok(chunk) if chunk.is_empty() => {} + Ok(chunk) => { + if bytes_tx.blocking_send(chunk).is_err() { + let _ = poll.registry().deregister(&mut serial); + return; + } + } + Err(e) => { + eprintln!("Victor serial read error: {e}"); + let _ = poll.registry().deregister(&mut serial); + return; + } + } + } + } + Err(e) => { + eprintln!("Victor serial poll error: {e}"); + break; + } + } + } + + let _ = poll.registry().deregister(&mut serial); + }); + + Self { + bytes_rx, + reader_handle, + stop, + } + } + + async fn next_chunk(&mut self) -> Option> { + self.bytes_rx.recv().await + } + + async fn shutdown(self) { + self.stop.store(true, Ordering::Release); + let _ = self.reader_handle.await; + } +} + +async fn run_serial_loop( + mut serial: mio_serial::SerialStream, + protocol: VictorReadonlyProtocol, + mut shutdown_rx: oneshot::Receiver<()>, + dispatch: TaskDispatch, + mut capture: Option, + value_debug_shared: Arc>, + device_shared: Arc>, +) { + let _ = serial.clear(ClearBuffer::Input); + // Harmless on 86E if ignored; required on some 86B/C/D CP2102 links. + let _ = serial.write_data_terminal_ready(true); + + if *value_debug_shared.lock().unwrap() { + println!("{}", protocol.startup_log()); + if let Some(name) = serial.name() { + println!("Victor serial port: {name}"); + } + } + + { + let mut dev = device_shared.lock().unwrap(); + *dev = protocol.device_label().to_owned(); + } + + let mut reader = SerialReader::spawn(serial); + let mut decoder = Decoder::new(protocol); + let mut active_capture: Option = None; + let mut shutting_down = false; + + loop { + let capture_deadline = active_capture.as_ref().map(|c| c.ends_at); + + tokio::select! { + _ = &mut shutdown_rx, if !shutting_down => { + shutting_down = true; + } + job = async { + match capture.as_mut() { + Some(side) => side.rx.recv().await, + None => std::future::pending().await, + } + }, if active_capture.is_none() && capture.is_some() => { + if let Some(job) = job { + if let Some(side) = &capture { + write_capture_status( + &side.status, + format!( + "Recording {} ms for {}…", + job.duration_ms, + job.context.summary(), + ), + 0, + ); + } + let duration = Duration::from_millis(job.duration_ms); + active_capture = Some(ActiveCapture { + job, + buf: Vec::new(), + ends_at: Instant::now() + duration, + }); + } + } + _ = async { + if let Some(deadline) = capture_deadline { + sleep_until(deadline).await; + } + }, if capture_deadline.is_some() => { + if let (Some(cap), Some(side)) = (active_capture.take(), &capture) { + let debug = *value_debug_shared.lock().unwrap(); + finish_labeled_capture(cap, debug, side); + } + } + chunk = reader.next_chunk(), if !shutting_down => { + let Some(chunk) = chunk else { break; }; + let debug = *value_debug_shared.lock().unwrap(); + decoder + .feed_and_dispatch(protocol, &chunk, &mut active_capture, &dispatch, debug) + .await; + } + } + + if shutting_down { + break; + } + } + + reader.shutdown().await; + + if *value_debug_shared.lock().unwrap() { + println!("Victor serial task shutting down ({protocol:?})"); + } +} + +impl super::MyApp { + pub fn spawn_victor_readonly_serial_task(&mut self, protocol: VictorReadonlyProtocol) { + if self.serial.is_none() { + return; + } + + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + self.shutdown_tx = Some(shutdown_tx); + self.serial_tx = None; + + let serial = self.serial.take().unwrap(); + let value_debug_shared = self.value_debug_shared.clone(); + let device_shared = self.device.clone(); + + let (dispatch, capture) = match protocol { + VictorReadonlyProtocol::Es519xx => { + let (tx_value, rx_value) = mpsc::channel::>(100); + let (tx_mode, rx_mode) = mpsc::channel::<(MeterMode, String)>(10); + self.serial_rx = Some(rx_value); + self.mode_rx = Some(rx_mode); + self.victor_86bcd_rx = None; + (TaskDispatch::Es519xx { tx_value, tx_mode }, None) + } + VictorReadonlyProtocol::Dm1107 => { + let (capture_tx, capture_rx) = mpsc::channel::(8); + let (tx_live, rx_live) = mpsc::channel::(100); + self.victor_86bcd_capture_tx = Some(capture_tx); + self.victor_86bcd_rx = Some(rx_live); + self.serial_rx = None; + self.mode_rx = None; + ( + TaskDispatch::Dm1107 { tx_live }, + Some(CaptureSide { + rx: capture_rx, + status: self.victor_86bcd_capture_status_shared.clone(), + }), + ) + } + }; + + tokio::spawn(async move { + run_serial_loop( + serial, + protocol, + shutdown_rx, + dispatch, + capture, + value_debug_shared, + device_shared, + ) + .await; + }); + } + + pub fn spawn_victor_86e_serial_task(&mut self) { + self.spawn_victor_readonly_serial_task(VictorReadonlyProtocol::Es519xx); + } + + pub fn spawn_victor_86bcd_serial_task(&mut self) { + self.spawn_victor_readonly_serial_task(VictorReadonlyProtocol::Dm1107); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn protocol_labels_differ() { + assert_ne!( + VictorReadonlyProtocol::Es519xx.device_label(), + VictorReadonlyProtocol::Dm1107.device_label(), + ); + } +} diff --git a/src/bin/victor_86bcd_explore.rs b/src/bin/victor_86bcd_explore.rs new file mode 100644 index 0000000..a22b26d --- /dev/null +++ b/src/bin/victor_86bcd_explore.rs @@ -0,0 +1,122 @@ +//! Offline analysis of Victor 86B/C/D DM1107 serial captures. +//! +//! ```text +//! cargo run --bin victor-86bcd-explore +//! ``` + +use std::env; + +use rusty_meter::victor_86bcd_capture; +use rusty_meter::victor_dm1107::{self, MeterFunction, VictorFrame}; + +struct Sample { + label: String, + display: rusty_meter::victor_86bcd_capture::LcdDisplay, + raw: Vec, +} + +fn load_latest(path: &str) -> Vec { + let text = std::fs::read_to_string(path).unwrap_or_else(|e| panic!("read {path}: {e}")); + victor_86bcd_capture::latest_samples_by_key(&text) + .into_iter() + .map(|row| Sample { + label: row.context.summary(), + display: row.context.display, + raw: row.raw, + }) + .collect() +} + +fn function_label(f: MeterFunction) -> &'static str { + match f { + MeterFunction::Vdc => "VDC", + MeterFunction::Vac => "VAC", + MeterFunction::Adc => "ADC", + MeterFunction::Aac => "AAC", + MeterFunction::Res => "RES", + MeterFunction::Cap => "CAP", + MeterFunction::Freq => "FREQ", + MeterFunction::Duty => "DUTY", + MeterFunction::Diod => "DIOD", + MeterFunction::Cont => "CONT", + MeterFunction::Temp => "TEMP", + MeterFunction::Unknown => "?", + } +} + +fn digit_hex(frame: &VictorFrame) -> String { + frame + .digits + .iter() + .map(|d| format!("{:02x}", d.raw)) + .collect::>() + .join(" ") +} + +fn main() { + let path = env::args() + .nth(1) + .unwrap_or_else(|| "data/victor_serial/victor_serial_samples.csv".to_owned()); + + let samples = load_latest(&path); + + println!("=== Victor 86B/C/D DM1107 serial, 20-byte `a5 12` frames ==="); + println!("File: {path} ({} unique captures)\n", samples.len()); + + println!("--- Frame decode ---"); + println!( + "{:<32} {:>6} mode decode (labeled)", + "context", "labeled" + ); + + let mut framed = 0usize; + let mut decoded_ok = 0usize; + for sample in &samples { + let Some(frame) = victor_dm1107::find_frame(&sample.raw) else { + println!("{:<32} (no a5 12 frame)", sample.label); + continue; + }; + framed += 1; + let reading = victor_dm1107::decode_frame(&frame); + let mode = format!( + "{} {:?} {:?}", + function_label(frame.decoded_mode.function), + frame.decoded_mode.unit, + frame.decoded_mode.dp_mode, + ); + let labeled = sample.display.format(); + let mark = if reading.text == labeled + || (reading.text.replace('_', "") == labeled.replace('_', "")) + { + decoded_ok += 1; + "ok" + } else if reading.text.is_empty() { + "??" + } else { + "~~" + }; + println!( + "{:<32} {:>6} {:<12} {} ({mark}, conf={}) wire={}", + sample.label, + labeled, + mode, + reading.text, + reading.confidence, + digit_hex(&frame), + ); + } + println!( + "({framed}/{} framed, {decoded_ok}/{} exact text match)\n", + samples.len(), + samples.len() + ); + + println!("--- Protocol ---"); + println!(" • DM1107 meter IC → opto isolation → CP2102 USB serial (9600 8N1)."); + println!( + " • 20-byte repeating frames: `a5 12` sync, 3-byte mode header, 4 digit bytes, tail `.. 04 ..`." + ); + println!(" • Digit bytes: low 7 bits = segment map, bit 7 = decimal point (positions 0–2)."); + println!(" • `0x5f` = lit zero; `0x00` = off; `0x80` = DP only; `0xdf` = zero + DP."); + println!(" • Mode annunciators are single bits across bytes 2–4 and 9 (V/m/DC/−/AUTO/…)."); +} diff --git a/src/helpers.rs b/src/helpers.rs index 465f890..9e0b453 100644 --- a/src/helpers.rs +++ b/src/helpers.rs @@ -1,5 +1,8 @@ use crate::multimeter::MeterMode; +/// Sentinel value for open-line / overload on SCPI and Victor DM1107 meters. +pub const METER_OVERLOAD_VALUE: f64 = 1e9; + pub fn format_measurement( value: f64, max_digits: usize, @@ -7,13 +10,14 @@ pub fn format_measurement( sci_threshold_low: f64, meter_mode: &MeterMode, auto_scale_units: bool, + lcd_override: Option<(&str, &str)>, ) -> (String, String) { if value.is_nan() { return (" NaN".to_string(), "".to_string()); } // Check for overload/open condition (1e9) in specific modes - if value == 1e9 + if value == METER_OVERLOAD_VALUE && matches!( meter_mode, MeterMode::Diod | MeterMode::Cont | MeterMode::Res @@ -22,6 +26,16 @@ pub fn format_measurement( return ("OVERLOAD".to_string(), "".to_string()); } + // Victor 6000-count meters: show wire-decoded LCD text (4 digits), unit from annunciator. + if let Some((lcd, unit)) = lcd_override { + if !lcd.is_empty() { + return ( + format!("{:>width$}", lcd, width = max_digits), + unit.to_string(), + ); + } + } + let abs_value = value.abs(); let mut display_value = value; let mut display_unit = match meter_mode { @@ -33,6 +47,7 @@ pub fn format_measurement( MeterMode::Cap => "F", MeterMode::Freq => "Hz", MeterMode::Per => "s", + MeterMode::Duty => "%", MeterMode::Diod => "V", MeterMode::Cont => "Ohm", MeterMode::Temp => "°C", diff --git a/src/lib.rs b/src/lib.rs index adfb9f9..f3a2870 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,3 +5,11 @@ mod app; pub use app::MyApp; mod helpers; mod multimeter; +#[cfg(not(target_arch = "wasm32"))] +pub mod victor_86bcd_capture; +#[cfg(not(target_arch = "wasm32"))] +pub mod victor_dm1107; +#[cfg(not(target_arch = "wasm32"))] +mod victor_es519xx; +#[cfg(not(target_arch = "wasm32"))] +mod victor_fs9922; diff --git a/src/multimeter.rs b/src/multimeter.rs index 0a6cc5e..c9d4be1 100644 --- a/src/multimeter.rs +++ b/src/multimeter.rs @@ -25,6 +25,7 @@ pub enum MeterMode { Cap, Freq, Per, + Duty, Diod, Cont, Temp, diff --git a/src/victor_86bcd_capture.rs b/src/victor_86bcd_capture.rs new file mode 100644 index 0000000..3ed6e06 --- /dev/null +++ b/src/victor_86bcd_capture.rs @@ -0,0 +1,703 @@ +//! Labeled serial capture for Victor 86B/C/D meters (DM1107 USB-serial). +//! +//! On these meters, **manual range** means a fixed decimal-point position +//! (not a traditional 20 V / 200 mV knob). **Unit** (V, mV, µA, …) is a separate +//! LCD annunciator — reflected in the wire stream. +//! +//! Captures store the LCD as four explicit digit slots (`d0`–`d3`) plus `dp_after` +//! (decimal after digit 0, 1, or 2). Use `_` for a digit that is completely off; +//! `0`–`9` for a lit digit. This avoids spreadsheet coercion (`00.00` → `0`) and +//! keeps off-vs-zero distinct for protocol correlation. +//! +//! Not used for Victor 86E (ES51932 serial) or legacy HID (FS9922) meters. + +use std::fs::OpenOptions; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +use chrono::Local; +use serde::{Deserialize, Serialize}; + +use crate::victor_dm1107; + +/// Measurement function selected on the meter (user-declared). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum Victor86bcdCaptureFunction { + #[default] + Vdc, + Vac, + Adc, + Aac, + Res, + Cap, + Freq, + Per, + Duty, + Diod, + Cont, + Temp, + Other, +} + +impl Victor86bcdCaptureFunction { + pub fn label(self) -> &'static str { + match self { + Self::Vdc => "VDC", + Self::Vac => "VAC", + Self::Adc => "ADC", + Self::Aac => "AAC", + Self::Res => "RES", + Self::Cap => "CAP", + Self::Freq => "FREQ", + Self::Per => "PER", + Self::Duty => "DUTY", + Self::Diod => "DIOD", + Self::Cont => "CONT", + Self::Temp => "TEMP", + Self::Other => "OTHER", + } + } + + pub fn default_unit(self) -> Victor86bcdCaptureUnit { + match self { + Self::Vdc | Self::Vac | Self::Diod => Victor86bcdCaptureUnit::V, + Self::Adc => Victor86bcdCaptureUnit::A, + Self::Aac => Victor86bcdCaptureUnit::A, + Self::Res | Self::Cont => Victor86bcdCaptureUnit::Ohm, + Self::Cap => Victor86bcdCaptureUnit::Nf, + Self::Freq => Victor86bcdCaptureUnit::Hz, + Self::Per | Self::Duty => Victor86bcdCaptureUnit::Percent, + Self::Temp => Victor86bcdCaptureUnit::Celsius, + Self::Other => Victor86bcdCaptureUnit::Unknown, + } + } + + pub fn all() -> &'static [Self] { + &[ + Self::Vdc, + Self::Vac, + Self::Adc, + Self::Aac, + Self::Res, + Self::Cap, + Self::Freq, + Self::Per, + Self::Duty, + Self::Diod, + Self::Cont, + Self::Temp, + Self::Other, + ] + } +} + +/// Unit annunciator lit on the LCD (separate from function dial). +#[allow(non_camel_case_types)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum Victor86bcdCaptureUnit { + #[default] + V, + mV, + A, + mA, + uA, + Ohm, + kOhm, + MOhm, + Nf, + Uf, + Hz, + Percent, + Celsius, + Fahrenheit, + Unknown, +} + +impl Victor86bcdCaptureUnit { + pub fn label(self) -> &'static str { + match self { + Self::V => "V", + Self::mV => "mV", + Self::A => "A", + Self::mA => "mA", + Self::uA => "uA", + Self::Ohm => "Ω", + Self::kOhm => "kΩ", + Self::MOhm => "MΩ", + Self::Nf => "nF", + Self::Uf => "µF", + Self::Hz => "Hz", + Self::Percent => "%", + Self::Celsius => "°C", + Self::Fahrenheit => "°F", + Self::Unknown => "?", + } + } + + pub fn all() -> &'static [Self] { + &[ + Self::V, + Self::mV, + Self::A, + Self::mA, + Self::uA, + Self::Ohm, + Self::kOhm, + Self::MOhm, + Self::Nf, + Self::Uf, + Self::Hz, + Self::Percent, + Self::Celsius, + Self::Fahrenheit, + Self::Unknown, + ] + } +} + +/// Fixed decimal-point position (manual) vs autorange moving the decimal (auto). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum Victor86bcdCaptureDpMode { + /// Decimal point locked by the range dial. + #[default] + Manual, + /// Meter chose decimal placement. + Auto, +} + +impl Victor86bcdCaptureDpMode { + pub fn label(self) -> &'static str { + match self { + Self::Manual => "manual DP", + Self::Auto => "auto DP", + } + } + + pub fn csv_value(self) -> &'static str { + match self { + Self::Manual => "manual", + Self::Auto => "auto", + } + } + + pub fn all() -> &'static [Self] { + &[Self::Manual, Self::Auto] + } +} + +/// One LCD digit position: completely off, a lit numeral, or overload `L`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum LcdDigit { + /// Digit position blank on the LCD (`_` in CSV). + #[default] + Off, + D0, + D1, + D2, + D3, + D4, + D5, + D6, + D7, + D8, + D9, + /// Overload / open-line `L` (usually last digit). + L, +} + +impl LcdDigit { + pub fn csv_value(self) -> &'static str { + match self { + Self::Off => "_", + Self::D0 => "0", + Self::D1 => "1", + Self::D2 => "2", + Self::D3 => "3", + Self::D4 => "4", + Self::D5 => "5", + Self::D6 => "6", + Self::D7 => "7", + Self::D8 => "8", + Self::D9 => "9", + Self::L => "L", + } + } + + pub fn ui_label(self) -> &'static str { + match self { + Self::Off => "off", + Self::L => "L", + d => d.csv_value(), + } + } + + pub fn all() -> &'static [Self] { + &[ + Self::Off, + Self::D0, + Self::D1, + Self::D2, + Self::D3, + Self::D4, + Self::D5, + Self::D6, + Self::D7, + Self::D8, + Self::D9, + Self::L, + ] + } + + pub fn is_configured(self) -> bool { + !matches!(self, Self::Off) + } +} + +/// Four-digit LCD layout with explicit decimal placement. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct LcdDisplay { + pub digits: [LcdDigit; 4], + /// Decimal point after digit index 0, 1, or 2. `None` = no decimal shown. + pub dp_after: Option, +} + +impl LcdDisplay { + pub fn is_empty(self) -> bool { + self.digits.iter().all(|d| !d.is_configured()) && self.dp_after.is_none() + } + + /// Human-readable preview: `00.00`, `__.1L`, `_1.23`, etc. + pub fn format(&self) -> String { + let mut out = String::new(); + for (i, digit) in self.digits.iter().enumerate() { + out.push(match digit { + LcdDigit::Off => '_', + LcdDigit::L => 'L', + d => d.csv_value().chars().next().unwrap_or('_'), + }); + if self.dp_after == Some(i as u8) { + out.push('.'); + } + } + out + } + + pub fn sort_key(&self) -> String { + format!( + "{},{},{},{},{}", + self.digits[0].csv_value(), + self.digits[1].csv_value(), + self.digits[2].csv_value(), + self.digits[3].csv_value(), + self.dp_after + .map(|n| n.to_string()) + .unwrap_or_else(|| "_".to_owned()) + ) + } +} + +fn parse_lcd_digit_field(field: &str) -> Option { + match field.trim() { + "_" | "-" | "" => Some(LcdDigit::Off), + "0" => Some(LcdDigit::D0), + "1" => Some(LcdDigit::D1), + "2" => Some(LcdDigit::D2), + "3" => Some(LcdDigit::D3), + "4" => Some(LcdDigit::D4), + "5" => Some(LcdDigit::D5), + "6" => Some(LcdDigit::D6), + "7" => Some(LcdDigit::D7), + "8" => Some(LcdDigit::D8), + "9" => Some(LcdDigit::D9), + "L" | "l" => Some(LcdDigit::L), + _ => None, + } +} + +fn parse_dp_after_field(field: &str) -> Option> { + match field.trim() { + "_" | "-" | "" => Some(None), + "0" => Some(Some(0)), + "1" => Some(Some(1)), + "2" => Some(Some(2)), + _ => None, + } +} + +/// Full user-declared meter state for one labeled capture. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Victor86bcdCaptureContext { + pub function: Victor86bcdCaptureFunction, + /// LCD unit annunciator (V, mV, µA, …). + pub unit: Victor86bcdCaptureUnit, + /// Manual fixed decimal vs autorange. + pub dp_mode: Victor86bcdCaptureDpMode, + /// Four LCD digit slots + decimal placement. + pub display: LcdDisplay, + /// Optional extras: REL, MAX/MIN, hold, probe notes, etc. + pub notes: String, +} + +impl Default for Victor86bcdCaptureContext { + fn default() -> Self { + Self { + function: Victor86bcdCaptureFunction::default(), + unit: Victor86bcdCaptureFunction::default().default_unit(), + dp_mode: Victor86bcdCaptureDpMode::default(), + display: LcdDisplay::default(), + notes: String::new(), + } + } +} + +impl Victor86bcdCaptureContext { + /// Short label for status messages and explore output. + pub fn summary(&self) -> String { + let mut parts = vec![format!( + "{} {} {}", + self.function.label(), + self.unit.label(), + self.dp_mode.label() + )]; + parts.push(self.display.format()); + if !self.notes.trim().is_empty() { + parts.push(format!("({})", self.notes.trim())); + } + parts.join(" ") + } + + /// Unique key for offline tools (same digits in different unit/DP mode stay distinct). + pub fn sample_key(&self) -> String { + let notes = self.notes.trim(); + if notes.is_empty() { + format!( + "{}|{}|{}|{}", + self.function.label(), + self.unit.label(), + self.dp_mode.csv_value(), + self.display.sort_key() + ) + } else { + format!( + "{}|{}|{}|{}|{}", + self.function.label(), + self.unit.label(), + self.dp_mode.csv_value(), + self.display.sort_key(), + notes + ) + } + } +} + +/// Capture request: record every byte received for `duration_ms`. +#[derive(Debug, Clone)] +pub struct Victor86bcdCaptureJob { + pub context: Victor86bcdCaptureContext, + pub duration_ms: u64, +} + +#[derive(Debug, Clone, Default)] +pub struct Victor86bcdCaptureStatus { + pub message: String, + pub bytes_written: usize, +} + +pub const SAMPLES_CSV_HEADER: &str = + "timestamp,function,unit,dp_mode,d0,d1,d2,d3,dp_after,notes,duration_ms,byte_count,raw_hex"; + +pub fn default_samples_path() -> PathBuf { + PathBuf::from("data/victor_serial/victor_serial_samples.csv") +} + +fn csv_field(value: &str) -> String { + if value.contains(['"', ',', '\n', '\r']) { + format!("\"{}\"", value.replace('"', "\"\"")) + } else { + value.to_owned() + } +} + +fn write_csv_row( + file: &mut impl Write, + timestamp: &str, + context: &Victor86bcdCaptureContext, + duration_ms: u64, + raw: &[u8], +) -> io::Result<()> { + let ts = timestamp; + let hex = victor_dm1107::hex_encode(raw); + let dp_after = context + .display + .dp_after + .map(|n| n.to_string()) + .unwrap_or_else(|| "_".to_owned()); + writeln!( + file, + "{ts},{},{},{},{},{},{},{},{},{},{duration_ms},{},{}", + context.function.label(), + context.unit.label(), + context.dp_mode.csv_value(), + context.display.digits[0].csv_value(), + context.display.digits[1].csv_value(), + context.display.digits[2].csv_value(), + context.display.digits[3].csv_value(), + dp_after, + csv_field(context.notes.trim()), + raw.len(), + csv_hex_field(&hex), + ) +} + +fn csv_hex_field(hex: &str) -> String { + format!("\"{}\"", hex.replace('"', "\"\"")) +} + +pub fn append_labeled_capture( + path: &Path, + context: &Victor86bcdCaptureContext, + duration_ms: u64, + raw: &[u8], +) -> io::Result { + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent)?; + } + } + let needs_header = !path.exists() || path.metadata()?.len() == 0; + let mut file = OpenOptions::new().create(true).append(true).open(path)?; + if needs_header { + writeln!(file, "{SAMPLES_CSV_HEADER}")?; + } + let ts = Local::now().format("%Y-%m-%dT%H:%M:%S%.3f").to_string(); + write_csv_row(&mut file, &ts, context, duration_ms, raw)?; + Ok(raw.len()) +} + +/// One parsed CSV row (current structured LCD format only). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Victor86bcdCaptureRow { + pub timestamp: String, + pub context: Victor86bcdCaptureContext, + pub duration_ms: u64, + pub raw: Vec, +} + +fn parse_data_line(line: &str) -> Option { + let (prefix, tail) = split_tail_fields(line, 3)?; + let duration_ms: u64 = tail[0].parse().ok()?; + let _byte_count: usize = tail[1].parse().ok()?; + let raw = parse_hex_field(tail[2]); + let mut parts: Vec<&str> = prefix.split(',').collect(); + while parts.last().is_some_and(|s| s.is_empty()) { + parts.pop(); + } + if parts.len() < 10 { + return None; + } + let function = parse_function_label(parts[1]); + let notes = if parts.len() >= 10 { + parts[9].to_owned() + } else { + String::new() + }; + let context = Victor86bcdCaptureContext { + function, + unit: parse_unit_label(parts[2]).unwrap_or_else(|| function.default_unit()), + dp_mode: parse_dp_mode_label(parts[3]), + display: LcdDisplay { + digits: [ + parse_lcd_digit_field(parts[4])?, + parse_lcd_digit_field(parts[5])?, + parse_lcd_digit_field(parts[6])?, + parse_lcd_digit_field(parts[7])?, + ], + dp_after: parse_dp_after_field(parts[8])?, + }, + notes, + }; + Some(Victor86bcdCaptureRow { + timestamp: parts[0].to_owned(), + context, + duration_ms, + raw, + }) +} + +fn split_tail_fields(line: &str, n: usize) -> Option<(&str, Vec<&str>)> { + let mut rest = line; + let mut tail = Vec::with_capacity(n); + for _ in 0..n { + let (left, right) = rest.rsplit_once(',')?; + tail.push(right); + rest = left; + } + tail.reverse(); + Some((rest, tail)) +} + +pub fn parse_samples_csv(text: &str) -> Vec { + let mut lines = text.lines(); + let header = lines.next().unwrap_or(""); + if header.trim().is_empty() { + return Vec::new(); + } + + let mut rows = Vec::new(); + for line in lines { + if line.trim().is_empty() { + continue; + } + if let Some(row) = parse_data_line(line) { + rows.push(row); + } + } + rows +} + +/// Latest row per [`Victor86bcdCaptureContext::sample_key`]. +pub fn latest_samples_by_key(text: &str) -> Vec { + let mut latest = std::collections::HashMap::new(); + for row in parse_samples_csv(text) { + latest.insert(row.context.sample_key(), row); + } + let mut out: Vec<_> = latest.into_values().collect(); + out.sort_by(|a, b| { + a.context + .display + .sort_key() + .cmp(&b.context.display.sort_key()) + }); + out +} + +fn parse_hex_field(field: &str) -> Vec { + let trimmed = field.trim().trim_matches('"'); + trimmed + .split_whitespace() + .filter_map(|t| u8::from_str_radix(t, 16).ok()) + .collect() +} + +fn parse_function_label(label: &str) -> Victor86bcdCaptureFunction { + match label.trim().to_ascii_uppercase().as_str() { + "VDC" => Victor86bcdCaptureFunction::Vdc, + "VAC" => Victor86bcdCaptureFunction::Vac, + "ADC" => Victor86bcdCaptureFunction::Adc, + "AAC" => Victor86bcdCaptureFunction::Aac, + "RES" => Victor86bcdCaptureFunction::Res, + "CAP" => Victor86bcdCaptureFunction::Cap, + "FREQ" => Victor86bcdCaptureFunction::Freq, + "PER" => Victor86bcdCaptureFunction::Per, + "DUTY" => Victor86bcdCaptureFunction::Duty, + "DIOD" => Victor86bcdCaptureFunction::Diod, + "CONT" => Victor86bcdCaptureFunction::Cont, + "TEMP" => Victor86bcdCaptureFunction::Temp, + _ => Victor86bcdCaptureFunction::Other, + } +} + +fn parse_dp_mode_label(label: &str) -> Victor86bcdCaptureDpMode { + match label.trim().to_ascii_lowercase().as_str() { + "auto" | "auto dp" => Victor86bcdCaptureDpMode::Auto, + _ => Victor86bcdCaptureDpMode::Manual, + } +} + +fn parse_unit_label(label: &str) -> Option { + match label.trim() { + "V" => Some(Victor86bcdCaptureUnit::V), + "mV" => Some(Victor86bcdCaptureUnit::mV), + "A" => Some(Victor86bcdCaptureUnit::A), + "mA" => Some(Victor86bcdCaptureUnit::mA), + "uA" | "µA" => Some(Victor86bcdCaptureUnit::uA), + "Ω" | "Ohm" => Some(Victor86bcdCaptureUnit::Ohm), + "kΩ" | "kOhm" => Some(Victor86bcdCaptureUnit::kOhm), + "MΩ" | "MOhm" => Some(Victor86bcdCaptureUnit::MOhm), + "nF" => Some(Victor86bcdCaptureUnit::Nf), + "µF" | "uF" => Some(Victor86bcdCaptureUnit::Uf), + "Hz" => Some(Victor86bcdCaptureUnit::Hz), + "%" => Some(Victor86bcdCaptureUnit::Percent), + "°C" | "C" => Some(Victor86bcdCaptureUnit::Celsius), + "°F" | "F" => Some(Victor86bcdCaptureUnit::Fahrenheit), + "" => None, + _ => Some(Victor86bcdCaptureUnit::Unknown), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn writes_and_parses_structured_lcd_csv() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("samples.csv"); + let context = Victor86bcdCaptureContext { + function: Victor86bcdCaptureFunction::Vdc, + unit: Victor86bcdCaptureUnit::mV, + dp_mode: Victor86bcdCaptureDpMode::Manual, + display: LcdDisplay { + digits: [LcdDigit::D0, LcdDigit::D0, LcdDigit::D0, LcdDigit::D0], + dp_after: Some(1), + }, + notes: "REL on".to_owned(), + }; + append_labeled_capture(&path, &context, 500, &[0xa5, 0x12]).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains(",0,0,0,0,1,REL on,")); + let rows = parse_samples_csv(&text); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].context, context); + assert_eq!(rows[0].duration_ms, 500); + assert_eq!(rows[0].raw, vec![0xa5, 0x12]); + } + + #[test] + fn structured_lcd_distinguishes_off_from_zero() { + let zero = LcdDisplay { + digits: [LcdDigit::D0, LcdDigit::D0, LcdDigit::D0, LcdDigit::D0], + dp_after: Some(1), + }; + let off = LcdDisplay { + digits: [LcdDigit::Off, LcdDigit::Off, LcdDigit::D0, LcdDigit::D0], + dp_after: Some(1), + }; + assert_eq!(zero.format(), "00.00"); + assert_eq!(off.format(), "__.00"); + assert_ne!(zero.sort_key(), off.sort_key()); + } + + #[test] + fn sample_key_distinguishes_unit_and_dp() { + let a = Victor86bcdCaptureContext { + function: Victor86bcdCaptureFunction::Vdc, + unit: Victor86bcdCaptureUnit::V, + dp_mode: Victor86bcdCaptureDpMode::Manual, + display: LcdDisplay { + digits: [LcdDigit::D0, LcdDigit::D0, LcdDigit::D0, LcdDigit::D0], + dp_after: Some(1), + }, + notes: String::new(), + }; + let b = Victor86bcdCaptureContext { + function: Victor86bcdCaptureFunction::Vdc, + unit: Victor86bcdCaptureUnit::mV, + dp_mode: Victor86bcdCaptureDpMode::Manual, + display: LcdDisplay { + digits: [LcdDigit::D0, LcdDigit::D0, LcdDigit::D0, LcdDigit::D0], + dp_after: Some(1), + }, + notes: String::new(), + }; + assert_ne!(a.sample_key(), b.sample_key()); + } + + #[test] + fn default_samples_path_uses_victor_serial_subdir() { + assert_eq!( + default_samples_path(), + PathBuf::from("data/victor_serial/victor_serial_samples.csv") + ); + } +} diff --git a/src/victor_dm1107.rs b/src/victor_dm1107.rs new file mode 100644 index 0000000..9d23687 --- /dev/null +++ b/src/victor_dm1107.rs @@ -0,0 +1,897 @@ +//! Victor DM1107 meters (e.g. 86D) — CP2102 serial protocol (9600 8N1). +//! +//! Newer Victor handhelds use a **DM1107** system IC and expose **only** a serial +//! link (CP2102 USB-UART). Meter logic is galvanically isolated from USB via an +//! optocoupler path — not the legacy HID/FS9922 cable used on discontinued 86B/C/D units. +//! +//! Each burst is a fixed **20-byte** frame: +//! ```text +//! a5 12 b2 b3 b4 [d3 d2 d1 d0] b9 pad… flg0-2 t0 04 t2 +//! ``` +//! +//! The four digit bytes on the wire are **rightmost-first** (`slice[8]` = LCD d0 / MSD, +//! `slice[5]` = LCD d3 / LSD). [`VictorFrame::digits`] is stored left-to-right for display. +//! +//! Digit bytes use **7 segment bits (low) + decimal point (bit 7)** on d0–d2. +//! `0x5f` is a lit **zero**; `0x00` is the digit position **off**; `0x80` is +//! **decimal point only** (digit off, DP lit). Combined: `0xdf = 0x5f|0x80` is zero + DP. +//! +//! Mode/range/unit annunciators are individual bits across bytes 2–4 and 9 (not +//! independent byte values). See [`LcdAnnunciators`]. + +use crate::helpers::METER_OVERLOAD_VALUE; +use crate::multimeter::MeterMode; + +pub const VICTOR_86BCD_BAUD: u32 = 9600; + +pub const SYNC: [u8; 2] = [0xa5, 0x12]; +pub const FRAME_LEN: usize = 20; +pub const TAIL_MARKER: u8 = 0x04; + +/// Low 7 bits = segment bitmap; bit 7 = decimal point after this digit (positions 0–2). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DigitCell { + pub raw: u8, +} + +impl DigitCell { + /// Lit zero segment pattern (not “off”). + pub const ZERO_SEG: u8 = 0x5f; + /// Digit position completely off. + pub const HARD_OFF: u8 = 0x00; + /// Digit off with only the decimal point lit. + pub const DP_ONLY: u8 = 0x80; + + pub fn from_raw(raw: u8) -> Self { + Self { raw } + } + + pub fn segments(self) -> u8 { + self.raw & 0x7f + } + + pub fn decimal_point(self) -> bool { + self.raw & 0x80 != 0 + } + + pub fn is_zero(self) -> bool { + self.segments() == Self::ZERO_SEG + } + + /// Digit position off: `0x00`, or `0x80` when only the DP is lit. + pub fn is_off(self) -> bool { + matches!(self.raw, Self::HARD_OFF | Self::DP_ONLY) + } + + pub fn is_dp_only(self) -> bool { + self.raw == Self::DP_ONLY + } +} + +/// Three-byte mode/range header after `a5 12`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ModeHeader { + pub b2: u8, + pub b3: u8, + pub b4: u8, +} + +/// LCD annunciator bits extracted from the mode header and delimiter byte. +/// +/// Each field maps to one on-screen symbol (V, m, DC, −, AUTO, …). Modes are +/// derived from which combination is lit, not from whole-byte templates. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct LcdAnnunciators { + /// Byte 9 bit 1 — DC marker. + pub dc: bool, + /// Byte 9 bit 3 — AC marker. + pub ac: bool, + /// Byte 9 bit 2 — minus (−) annunciator (independent of measurement function). + pub minus: bool, + /// Byte 4 bit 4 — AUTO range. + pub auto_range: bool, + /// Byte 4 bit 6 — MANUAL range (fixed decimal point). + pub manual_range: bool, + /// Byte 4 bit 2 — diode test. + pub diode: bool, + /// Byte 4 bit 3 — continuity. + pub continuity: bool, + /// Byte 2 bit 2 — volt (V) unit. + pub volt: bool, + /// Byte 2 bit 3 — milli (m) prefix. + pub milli: bool, + /// Byte 2 bit 1 — ampere (A) unit. + pub amp: bool, + /// Byte 2 bit 5 — ohm (Ω) unit. + pub ohm: bool, + /// Byte 2 bit 6 — kilo (k) prefix. + pub kilo: bool, + /// Byte 2 bit 7 — mega (M) prefix. + pub mega: bool, + /// Byte 2 bit 4 — hertz (Hz). + pub hertz: bool, + /// Byte 2 bit 0 — farad (F) capacitance. + pub farad: bool, + /// Byte 3 bit 2 — duty cycle (%). + pub duty: bool, + /// Byte 3 bit 5 — Celsius (°C). + pub celsius: bool, + /// Byte 3 bit 4 — Fahrenheit (°F). + pub fahrenheit: bool, + /// Byte 3 bit 6 — nano (n) prefix. + pub nano: bool, + /// Byte 3 bit 7 — micro (µ) prefix. + pub micro: bool, +} + +impl LcdAnnunciators { + pub fn from_mode_header(mode: ModeHeader, delimiter: u8) -> Self { + Self { + dc: delimiter & 0x02 != 0, + minus: delimiter & 0x04 != 0, + ac: delimiter & 0x08 != 0, + auto_range: mode.b4 & 0x10 != 0, + manual_range: mode.b4 & 0x40 != 0, + diode: mode.b4 & 0x04 != 0, + continuity: mode.b4 & 0x08 != 0, + volt: mode.b2 & 0x04 != 0, + milli: mode.b2 & 0x08 != 0, + amp: mode.b2 & 0x02 != 0, + ohm: mode.b2 & 0x20 != 0, + kilo: mode.b2 & 0x40 != 0, + mega: mode.b2 & 0x80 != 0, + hertz: mode.b2 & 0x10 != 0, + farad: mode.b2 & 0x01 != 0, + duty: mode.b3 & 0x04 != 0, + celsius: mode.b3 & 0x20 != 0, + fahrenheit: mode.b3 & 0x10 != 0, + nano: mode.b3 & 0x40 != 0, + micro: mode.b3 & 0x80 != 0, + } + } + + pub fn function_and_unit(self) -> (MeterFunction, MeterUnit) { + if self.diode { + return (MeterFunction::Diod, MeterUnit::V); + } + if self.continuity { + return (MeterFunction::Cont, MeterUnit::Ohm); + } + if self.celsius { + return (MeterFunction::Temp, MeterUnit::Celsius); + } + if self.fahrenheit { + return (MeterFunction::Temp, MeterUnit::Fahrenheit); + } + if self.duty { + return (MeterFunction::Duty, MeterUnit::Percent); + } + if self.hertz { + return (MeterFunction::Freq, MeterUnit::Hz); + } + if self.farad { + let unit = if self.micro { + MeterUnit::uF + } else if self.nano { + MeterUnit::nF + } else { + MeterUnit::Unknown + }; + return (MeterFunction::Cap, unit); + } + if self.ohm { + let unit = if self.mega { + MeterUnit::MOhm + } else if self.kilo { + MeterUnit::kOhm + } else { + MeterUnit::Ohm + }; + return (MeterFunction::Res, unit); + } + if self.volt { + let unit = if self.milli { + MeterUnit::mV + } else { + MeterUnit::V + }; + return if self.ac { + (MeterFunction::Vac, unit) + } else { + (MeterFunction::Vdc, unit) + }; + } + if self.amp { + let unit = if self.micro { + MeterUnit::uA + } else if self.milli { + MeterUnit::mA + } else { + MeterUnit::A + }; + return if self.ac { + (MeterFunction::Aac, unit) + } else { + (MeterFunction::Adc, unit) + }; + } + (MeterFunction::Unknown, MeterUnit::Unknown) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MeterFunction { + Vdc, + Vac, + Adc, + Aac, + Res, + Cap, + Freq, + Duty, + Diod, + Cont, + Temp, + Unknown, +} + +#[allow(non_camel_case_types)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MeterUnit { + V, + mV, + A, + mA, + uA, + Ohm, + kOhm, + MOhm, + nF, + uF, + Hz, + Percent, + Celsius, + Fahrenheit, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DpMode { + Manual, + Auto, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AcDc { + Dc, + Ac, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DisplayKind { + Normal, + /// `.0L`, `0.L`, bar graph full — first/last digit off, DP lit. + Overload, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DecodedMode { + pub function: MeterFunction, + pub unit: MeterUnit, + pub dp_mode: DpMode, + pub ac_dc: AcDc, + /// Minus (−) annunciator lit; digit bytes stay unsigned. + pub is_negative: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VictorFrame { + pub mode: ModeHeader, + pub decoded_mode: DecodedMode, + /// Four digit slots in LCD order: `[d0, d1, d2, d3]` (left to right). + pub digits: [DigitCell; 4], + pub delimiter: u8, + pub flags: [u8; 3], + pub tail: [u8; 3], + pub display_kind: DisplayKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DecodedReading { + pub text: String, + pub confidence: u8, +} + +/// One live decode from the serial stream (6000-count / 4-digit LCD meters). +#[derive(Debug, Clone, PartialEq)] +pub struct Dm1107LiveUpdate { + /// LCD text as shown on the meter (`21.40`, `00.00`, `.0L`, …). + pub display: String, + /// Parsed value for graph/recording when numeric. + pub value: Option, + pub mode: MeterMode, + /// Unit annunciator (`VDC`, `nF`, `Hz`, …) — not auto-scaled. + pub unit: String, +} + +/// Rolling buffer for live CP2102 stream decode. +#[derive(Debug, Default)] +pub struct Dm1107Stream { + buf: Vec, + last_text: Option, + last_mode_key: Option, +} + +impl Dm1107Stream { + pub fn new() -> Self { + Self::default() + } +} + +const STREAM_KEEP_MAX: usize = 64; + +/// Parse one aligned 20-byte frame starting at `offset`. +pub fn parse_frame_at(raw: &[u8], offset: usize) -> Option { + let slice = raw.get(offset..offset + FRAME_LEN)?; + if slice[0..2] != SYNC { + return None; + } + let mode = ModeHeader { + b2: slice[2], + b3: slice[3], + b4: slice[4], + }; + // Wire transmits d3..d0; store d0..d3 for LCD-left-to-right decode. + let digits = [ + DigitCell::from_raw(slice[8]), + DigitCell::from_raw(slice[7]), + DigitCell::from_raw(slice[6]), + DigitCell::from_raw(slice[5]), + ]; + let delimiter = slice[9]; + let flags = [slice[14], slice[15], slice[16]]; + let tail = [slice[17], slice[18], slice[19]]; + if tail[1] != TAIL_MARKER { + return None; + } + let decoded_mode = decode_mode_header(mode, delimiter); + let display_kind = classify_display(&digits, flags); + Some(VictorFrame { + mode, + decoded_mode, + digits, + delimiter, + flags, + tail, + display_kind, + }) +} + +/// Find the most recent valid `a5 12` frame in a buffer (live stream may retain older frames). +pub fn find_frame(raw: &[u8]) -> Option { + if raw.len() < FRAME_LEN { + return None; + } + let last_start = raw.len() - FRAME_LEN; + (0..=last_start).rev().find_map(|i| parse_frame_at(raw, i)) +} + +/// Decode the 3-byte mode header + delimiter into meter function/unit/DP mode. +pub fn decode_mode_header(mode: ModeHeader, delimiter: u8) -> DecodedMode { + let ann = LcdAnnunciators::from_mode_header(mode, delimiter); + let (function, unit) = ann.function_and_unit(); + let ac_dc = if ann.ac { AcDc::Ac } else { AcDc::Dc }; + let dp_mode = if ann.manual_range { + DpMode::Manual + } else { + DpMode::Auto + }; + + DecodedMode { + function, + unit, + dp_mode, + ac_dc, + is_negative: ann.minus, + } +} + +fn decoded_mode_key(dm: &DecodedMode) -> String { + format!( + "{:?}|{:?}|{:?}|{:?}|{}", + dm.function, dm.unit, dm.dp_mode, dm.ac_dc, dm.is_negative + ) +} + +fn apply_negative_sign(text: &str, is_negative: bool) -> String { + if is_negative && !text.is_empty() && !text.starts_with('-') && !is_overload_display(text) { + format!("-{text}") + } else { + text.to_owned() + } +} + +/// Map wire mode header to UI [`MeterMode`] and unit label. +pub fn meter_mode_and_unit(dm: &DecodedMode) -> (MeterMode, String) { + let mode = match dm.function { + MeterFunction::Vdc => MeterMode::Vdc, + MeterFunction::Vac => MeterMode::Vac, + MeterFunction::Adc => MeterMode::Adc, + MeterFunction::Aac => MeterMode::Aac, + MeterFunction::Res => MeterMode::Res, + MeterFunction::Cap => MeterMode::Cap, + MeterFunction::Freq => MeterMode::Freq, + MeterFunction::Duty => MeterMode::Duty, + MeterFunction::Diod => MeterMode::Diod, + MeterFunction::Cont => MeterMode::Cont, + MeterFunction::Temp => MeterMode::Temp, + MeterFunction::Unknown => MeterMode::Vdc, + }; + let unit = match (dm.function, dm.unit, dm.ac_dc) { + (MeterFunction::Vdc, MeterUnit::V, _) => "VDC".to_owned(), + (MeterFunction::Vdc, MeterUnit::mV, _) => "mVDC".to_owned(), + (MeterFunction::Vac, MeterUnit::V, _) => "VAC".to_owned(), + (MeterFunction::Vac, MeterUnit::mV, _) => "mVAC".to_owned(), + (MeterFunction::Adc, MeterUnit::A, _) => "ADC".to_owned(), + (MeterFunction::Adc, MeterUnit::mA, _) => "mADC".to_owned(), + (MeterFunction::Adc, MeterUnit::uA, _) => "µADC".to_owned(), + (MeterFunction::Aac, MeterUnit::A, _) => "AAC".to_owned(), + (MeterFunction::Aac, MeterUnit::mA, _) => "mAAC".to_owned(), + (MeterFunction::Aac, MeterUnit::uA, _) => "µAAC".to_owned(), + (MeterFunction::Res, MeterUnit::Ohm, _) => "Ω".to_owned(), + (MeterFunction::Res, MeterUnit::kOhm, _) => "kΩ".to_owned(), + (MeterFunction::Res, MeterUnit::MOhm, _) => "MΩ".to_owned(), + (MeterFunction::Cont, MeterUnit::Ohm, _) => "Ω".to_owned(), + (MeterFunction::Cap, MeterUnit::nF, _) => "nF".to_owned(), + (MeterFunction::Cap, MeterUnit::uF, _) => "µF".to_owned(), + (MeterFunction::Freq, MeterUnit::Hz, _) => "Hz".to_owned(), + (MeterFunction::Duty, MeterUnit::Percent, _) => "%".to_owned(), + (MeterFunction::Diod, MeterUnit::V, _) => "V".to_owned(), + (MeterFunction::Temp, MeterUnit::Celsius, _) => "°C".to_owned(), + (MeterFunction::Temp, MeterUnit::Fahrenheit, _) => "°F".to_owned(), + _ => "?".to_owned(), + }; + (mode, unit) +} + +/// Open-line / overload strings — no numeric value, must not trigger Cont/Diod thresholds. +pub fn is_overload_display(display: &str) -> bool { + matches!(display, "OL" | ".0L" | "0.L" | "OVERLOAD") || display.contains('L') +} + +fn parse_display_value(display: &str, mode: MeterMode) -> Option { + if is_overload_display(display) + && matches!(mode, MeterMode::Diod | MeterMode::Cont | MeterMode::Res) + { + return Some(METER_OVERLOAD_VALUE); + } + let stripped = display.trim(); + if stripped.is_empty() || stripped.ends_with('.') { + return None; + } + stripped.parse().ok() +} + +fn classify_display(digits: &[DigitCell; 4], flags: [u8; 3]) -> DisplayKind { + let overload_flags = flags == [0xff, 0xff, 0xff] || flags[0] == 0xff; + let ol_digits = digits[0].is_dp_only() + || digits[0].raw == DigitCell::HARD_OFF + || digits[3].raw == DigitCell::HARD_OFF; + if overload_flags || ol_digits { + DisplayKind::Overload + } else { + DisplayKind::Normal + } +} + +/// Empirical segment → digit/char (not standard GFEDCBA — DM1107 wire encoding). +fn glyph_for_segments(seg7: u8) -> Option { + match seg7 { + 0x5f => Some('0'), + 0x3d => Some('2'), + 0x50 => Some('1'), + 0x72 => Some('4'), + 0x79 => Some('3'), + 0x51 => Some('7'), + 0x7b => Some('9'), + 0x7f => Some('8'), + 0x6b => Some('5'), + 0x6f => Some('6'), + 0x0e | 0x0c => Some('0'), // overload / diode zero glyph + _ => None, + } +} + +/// Build display text from digit cells + decimal points. +pub fn format_digits(digits: &[DigitCell; 4]) -> DecodedReading { + // DIOD `.0L`: leading DP-only, `0` in the middle, LSD hard-off. + if digits[0].is_dp_only() + && digits[2].segments() == 0x0e + && digits[3].raw == DigitCell::HARD_OFF + { + return DecodedReading { + text: ".0L".to_owned(), + confidence: 90, + }; + } + // RES `0.L`: d1 = zero+DP (`0xdf`), d2 = overload glyph (`0x0e`). + if digits[1].is_zero() && digits[1].decimal_point() && digits[2].segments() == 0x0e { + return DecodedReading { + text: "0.L".to_owned(), + confidence: 90, + }; + } + // CONT open (`_0L._`): d1 = 0, d2 = `0x8e` (0x0e + DP), outer digits hard-off. + if digits[0].raw == DigitCell::HARD_OFF + && digits[1].is_zero() + && digits[2].segments() == 0x0e + && digits[2].decimal_point() + && digits[3].raw == DigitCell::HARD_OFF + { + return DecodedReading { + text: "OL".to_owned(), + confidence: 90, + }; + } + + let mut chars: Vec = Vec::new(); + + let mut known = 0u8; + let mut total = 0u8; + + for (i, cell) in digits.iter().enumerate() { + if cell.is_dp_only() { + chars.push('.'); + continue; + } + if cell.raw == DigitCell::HARD_OFF { + continue; + } + total += 1; + if let Some(c) = glyph_for_segments(cell.segments()) { + chars.push(c); + known += 1; + } else { + chars.push('?'); + } + if i < 3 && cell.decimal_point() { + chars.push('.'); + } + } + + let text: String = chars.into_iter().collect(); + let confidence = if total == 0 { + 0 + } else { + ((known as u16) * 100 / total as u16).min(100) as u8 + }; + DecodedReading { text, confidence } +} + +/// Full frame → human reading string (best effort). +pub fn decode_frame(frame: &VictorFrame) -> DecodedReading { + let mut reading = format_digits(&frame.digits); + if reading.confidence >= 50 && !reading.text.is_empty() { + reading.text = apply_negative_sign(&reading.text, frame.decoded_mode.is_negative); + return reading; + } + if frame.display_kind == DisplayKind::Overload { + let text = match frame.decoded_mode.function { + MeterFunction::Cont => "OL".to_owned(), + MeterFunction::Res => "0.L".to_owned(), + MeterFunction::Diod => ".0L".to_owned(), + _ => ".0L".to_owned(), + }; + return DecodedReading { + text, + confidence: 85, + }; + } + reading.text = apply_negative_sign(&reading.text, frame.decoded_mode.is_negative); + reading +} + +/// Append serial bytes; return new live readings (display + mode + optional value). +pub fn feed_bytes(state: &mut Dm1107Stream, chunk: &[u8]) -> Vec { + state.buf.extend_from_slice(chunk); + let mut out = Vec::new(); + + if let Some(frame) = find_frame(&state.buf) { + let reading = decode_frame(&frame); + let mode_key = decoded_mode_key(&frame.decoded_mode); + let usable = !reading.text.is_empty() + && (reading.confidence >= 50 || frame.display_kind == DisplayKind::Overload); + let changed = state.last_text.as_deref() != Some(reading.text.as_str()) + || state.last_mode_key.as_deref() != Some(mode_key.as_str()); + if usable && changed { + let display = reading.text.clone(); + state.last_text = Some(display.clone()); + state.last_mode_key = Some(mode_key); + let (mode, unit) = meter_mode_and_unit(&frame.decoded_mode); + out.push(Dm1107LiveUpdate { + display, + value: parse_display_value(&reading.text, mode), + mode, + unit, + }); + } + } + + if state.buf.len() > STREAM_KEEP_MAX { + let keep = STREAM_KEEP_MAX / 2; + let drain = state.buf.len().saturating_sub(keep); + state.buf.drain(..drain); + } + out +} + +/// Format bytes as lowercase hex (debug logging, CSV). +pub fn hex_encode(bytes: &[u8]) -> String { + bytes + .iter() + .map(|b| format!("{b:02x}")) + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_hex_line(hex: &str) -> Vec { + hex.split_whitespace() + .filter_map(|t| u8::from_str_radix(t, 16).ok()) + .collect() + } + + #[test] + fn hex_encodes_bytes() { + assert_eq!(hex_encode(&[0xa5, 0x12, 0x04]), "a5 12 04"); + } + + #[test] + fn parses_vdc_manual_zero_frame() { + let raw = parse_hex_line("a5 12 04 00 40 5f 5f df 5f 42 00 00 00 00 00 00 00 0c 04 49"); + let frame = parse_frame_at(&raw, 0).unwrap(); + assert_eq!(frame.mode.b2, 0x04); + assert_eq!(frame.mode.b4, 0x40); + assert_eq!(frame.decoded_mode.function, MeterFunction::Vdc); + assert_eq!(frame.decoded_mode.unit, MeterUnit::V); + assert_eq!(frame.decoded_mode.dp_mode, DpMode::Manual); + assert!(frame.digits[1].decimal_point()); + assert_eq!(frame.digits[1].segments(), 0x5f); + } + + #[test] + fn parses_vdc_21_34_payload() { + let raw = parse_hex_line("a5 12 04 00 40 72 79 d0 3d 42 00 00 00 00 01 ff ff fc 04 34"); + let frame = parse_frame_at(&raw, 0).unwrap(); + assert_eq!(frame.digits[0].segments(), 0x3d); + assert!(frame.digits[1].decimal_point()); + assert_eq!(frame.digits[1].segments(), 0x50); + assert_eq!(frame.digits[2].segments(), 0x79); + assert_eq!(frame.digits[3].segments(), 0x72); + assert_eq!(decode_frame(&frame).text, "21.34"); + assert_eq!(frame.tail[2], 0x34); + } + + #[test] + fn decodes_vdc_auto_21_40_capture() { + let raw = parse_hex_line("a5 12 04 00 10 5f 72 d0 3d 42 00 00 00 00 01 ff ff fc 04 ea"); + let frame = parse_frame_at(&raw, 0).unwrap(); + assert_eq!(frame.digits[0].segments(), 0x3d); + assert_eq!(frame.digits[1].segments(), 0x50); + assert!(frame.digits[1].decimal_point()); + assert_eq!(frame.digits[2].segments(), 0x72); + assert!(frame.digits[3].is_zero()); + assert_eq!(decode_frame(&frame).text, "21.40"); + } + + #[test] + fn decodes_manual_vdc_zero_layouts() { + let cases = [ + ( + "a5 12 04 00 40 5f 5f df 5f 42 00 00 00 00 00 00 00 0c 04 49", + "00.00", + ), + ( + "a5 12 04 00 40 5f 5f 5f df 42 00 00 00 00 00 00 00 0c 04 49", + "0.000", + ), + ( + "a5 12 04 00 40 5f df 5f 5f 42 00 00 00 00 00 00 00 0c 04 49", + "000.0", + ), + ]; + for (hex, want) in cases { + let raw = parse_hex_line(hex); + let frame = parse_frame_at(&raw, 0).unwrap(); + assert_eq!(decode_frame(&frame).text, want, "{hex}"); + } + } + + #[test] + fn decodes_mode_mv_and_res_ladder() { + let mv = parse_hex_line("a5 12 0c 00 10 5f df 5f 5f 42 00 00 00 00 00 00 00 0c 04 21"); + assert_eq!(find_frame(&mv).unwrap().decoded_mode.unit, MeterUnit::mV); + + for (b2, unit) in [ + (0x20, MeterUnit::Ohm), + (0x60, MeterUnit::kOhm), + (0xa0, MeterUnit::MOhm), + ] { + let mut raw = + parse_hex_line("a5 12 00 00 10 5f df 5f 5f 40 00 00 00 00 00 00 00 0c 04 33"); + raw[2] = b2; + assert_eq!(find_frame(&raw).unwrap().decoded_mode.unit, unit); + } + } + + #[test] + fn diode_ol_overload_pattern() { + let raw = parse_hex_line("a5 12 04 00 04 00 0e 5f 80 40 ff ff ff ff ff ff ff fc 04 e5"); + let frame = find_frame(&raw).unwrap(); + assert_eq!(frame.display_kind, DisplayKind::Overload); + assert!(frame.digits[0].is_dp_only()); + assert_eq!(frame.digits[2].segments(), 0x0e); + assert_eq!(frame.digits[3].raw, DigitCell::HARD_OFF); + assert_eq!(decode_frame(&frame).text, ".0L"); + } + + #[test] + fn decodes_negative_vdc_auto_21_19() { + let raw = parse_hex_line("a5 12 04 00 10 7b 50 d0 3d 46 00 00 00 00 01 ff ff fe 04 ea"); + let frame = parse_frame_at(&raw, 0).unwrap(); + assert_eq!(frame.delimiter, 0x46); + assert!(frame.decoded_mode.is_negative); + assert_eq!(frame.decoded_mode.function, MeterFunction::Vdc); + assert_eq!(frame.decoded_mode.unit, MeterUnit::V); + let reading = decode_frame(&frame); + assert_eq!(reading.text, "-21.19"); + let (mode, unit) = meter_mode_and_unit(&frame.decoded_mode); + assert_eq!(mode, MeterMode::Vdc); + assert_eq!(unit, "VDC"); + assert_eq!(parse_display_value(&reading.text, mode), Some(-21.19)); + } + + #[test] + fn annunciator_bits_from_captures() { + let vdc_auto = LcdAnnunciators::from_mode_header( + ModeHeader { + b2: 0x04, + b3: 0x00, + b4: 0x10, + }, + 0x42, + ); + assert!(vdc_auto.volt); + assert!(vdc_auto.dc); + assert!(vdc_auto.auto_range); + assert!(!vdc_auto.milli); + assert!(!vdc_auto.minus); + + let vdc_neg = LcdAnnunciators::from_mode_header( + ModeHeader { + b2: 0x04, + b3: 0x00, + b4: 0x10, + }, + 0x46, + ); + assert!(vdc_neg.minus); + assert_eq!( + vdc_neg.function_and_unit(), + (MeterFunction::Vdc, MeterUnit::V) + ); + + let mv_auto = LcdAnnunciators::from_mode_header( + ModeHeader { + b2: 0x0c, + b3: 0x00, + b4: 0x10, + }, + 0x42, + ); + assert!(mv_auto.volt); + assert!(mv_auto.milli); + assert_eq!( + mv_auto.function_and_unit(), + (MeterFunction::Vdc, MeterUnit::mV) + ); + + let mv_neg = LcdAnnunciators::from_mode_header( + ModeHeader { + b2: 0x0c, + b3: 0x00, + b4: 0x10, + }, + 0x46, + ); + assert!(mv_neg.minus); + assert_eq!( + mv_neg.function_and_unit(), + (MeterFunction::Vdc, MeterUnit::mV) + ); + } + + #[test] + fn decodes_negative_mv_without_breaking_unit() { + let mut raw = parse_hex_line("a5 12 0c 00 10 5f df 5f 5f 42 00 00 00 00 00 00 00 0c 04 21"); + raw[9] = 0x46; + let frame = find_frame(&raw).unwrap(); + assert_eq!(frame.decoded_mode.function, MeterFunction::Vdc); + assert_eq!(frame.decoded_mode.unit, MeterUnit::mV); + assert!(frame.decoded_mode.is_negative); + let (mode, unit) = meter_mode_and_unit(&frame.decoded_mode); + assert_eq!(unit, "mVDC"); + assert_eq!(mode, MeterMode::Vdc); + } + + #[test] + fn decodes_digit_six() { + // CAP µF capture: segment 0x6f = digit 6 (distinct from 0x7d seen elsewhere). + let raw = parse_hex_line("a5 12 01 80 10 6f bd 6f 72 40 00 03 ff ff ff ff ff fc 04 93"); + let frame = parse_frame_at(&raw, 0).unwrap(); + assert_eq!(frame.decoded_mode.function, MeterFunction::Cap); + assert_eq!(frame.decoded_mode.unit, MeterUnit::uF); + assert_eq!(decode_frame(&frame).text, "462.6"); + } + + #[test] + fn temp_shows_trailing_digits() { + let raw = parse_hex_line("a5 12 00 20 00 5f 79 5f 5f 40 00 00 00 00 00 00 00 0c 04 bd"); + let frame = find_frame(&raw).unwrap(); + assert!(frame.digits[0].is_zero()); + assert!(frame.digits[1].is_zero()); + assert_eq!(frame.digits[2].segments(), 0x79); + assert!(frame.digits[3].is_zero()); + assert_eq!(decode_frame(&frame).text, "0030"); + } + + #[test] + fn digit_cell_dp_bit() { + let cell = DigitCell::from_raw(0xdf); + assert_eq!(cell.segments(), 0x5f); + assert!(cell.decimal_point()); + assert!(!cell.is_off()); + } + + #[test] + fn feed_bytes_emits_mode_and_display() { + let frame = parse_hex_line("a5 12 04 00 10 5f 72 d0 3d 42 00 00 00 00 01 ff ff fc 04 ea"); + let mut stream = Dm1107Stream::new(); + let updates = feed_bytes(&mut stream, &frame); + assert_eq!(updates.len(), 1); + assert_eq!(updates[0].display, "21.40"); + assert_eq!(updates[0].value, Some(21.40)); + assert_eq!(updates[0].mode, MeterMode::Vdc); + assert_eq!(updates[0].unit, "VDC"); + assert!(feed_bytes(&mut stream, &frame).is_empty()); + } + + #[test] + fn cont_open_line_decodes_as_ol() { + let raw = parse_hex_line("a5 12 20 00 08 00 8e 5f 00 40 ff ff ff ff ff ff ff fc 04 05"); + let frame = find_frame(&raw).unwrap(); + assert_eq!(frame.decoded_mode.function, MeterFunction::Cont); + let reading = decode_frame(&frame); + assert_eq!(reading.text, "OL"); + assert!(is_overload_display(&reading.text)); + assert_eq!( + parse_display_value(&reading.text, MeterMode::Cont), + Some(METER_OVERLOAD_VALUE) + ); + } + + #[test] + fn feed_bytes_emits_on_mode_change() { + let vdc = parse_hex_line("a5 12 04 00 10 5f 72 d0 3d 42 00 00 00 00 01 ff ff fc 04 ea"); + let cap = parse_hex_line("a5 12 01 40 10 5f 5f 5f 5f 42 00 00 00 00 00 00 00 0c 04 00"); + let mut stream = Dm1107Stream::new(); + assert_eq!(feed_bytes(&mut stream, &vdc).len(), 1); + let cap_updates = feed_bytes(&mut stream, &cap); + assert_eq!(cap_updates.len(), 1); + assert_eq!(cap_updates[0].mode, MeterMode::Cap); + assert_eq!(cap_updates[0].unit, "nF"); + } +} diff --git a/src/victor_es519xx.rs b/src/victor_es519xx.rs new file mode 100644 index 0000000..65dcf4c --- /dev/null +++ b/src/victor_es519xx.rs @@ -0,0 +1,528 @@ +//! Cyrustek ES51932 (Victor / RuoShui 86E) serial protocol parser. +//! +//! The 86E uses a CP2102 USB-UART bridge and ES51932 ASCII frames (distinct from +//! legacy 86B/C/D HID meters and from newer DM1107 serial meters such as the 86D). +//! It streams read-only measurement frames; modes cannot be changed remotely. +//! +//! ## Serial settings +//! +//! - 19200 baud, 7 data bits, odd parity, 1 stop bit (7o1) +//! - Linux example: `stty -F /dev/ttyUSB0 19200 cs7 parenb parodd -cstopb raw -echo` +//! +//! ## Frame layout (14 bytes, CR/LF terminated) +//! +//! `[range][digit×5][function][status][opt1][opt2][opt3][opt4][CR][LF]` +//! +//! A line like `103560;000:3` is not human-padded text — the semicolon and colon +//! are literal protocol bytes. For example `;` (0x3b) encodes DC voltage mode. +//! +//! ## Sources +//! +//! - [rusty_meter#16](https://github.com/markusdd/rusty_meter/issues/16) — 86E +//! captures and serial parameters (Markus Krause / MFJoyBoy) +//! - [EEVblog teardown / ES51932_log](https://www.eevblog.com/forum/testgear/teardown-ruoshui-86e-22000-count-dmm-50-eur/) +//! — original 14-byte packet description and Perl decoder +//! - [libsigrok `es519xx.c`](https://github.com/sigrokproject/libsigrok/blob/master/src/dmm/es519xx.c) +//! — range exponents and function-byte mapping (ES51931/ES51932, 19200/14b) + +use crate::helpers::METER_OVERLOAD_VALUE; +use crate::multimeter::MeterMode; +use crate::victor_fs9922::VictorReading; + +pub const VICTOR_86E_BAUD: u32 = 19200; +pub const PACKET_LEN: usize = 14; + +/// Exponents for 19200 baud / 14-byte / ES51931/32 packets (from libsigrok). +const EXPONENTS_VOLTAGE: [i32; 8] = [-4, -3, -2, -1, -5, 0, 0, 0]; +const EXPONENTS_UA: [i32; 8] = [-8, -7, 0, 0, 0, 0, 0, 0]; +const EXPONENTS_MA: [i32; 8] = [-6, -5, 0, 0, 0, 0, 0, 0]; +const EXPONENTS_A: [i32; 8] = [-3, 0, 0, 0, 0, 0, 0, 0]; +const EXPONENTS_MANUAL_A: [i32; 8] = [-4, -3, -2, -1, 0, 0, 0, 0]; +const EXPONENTS_RES: [i32; 8] = [-2, -1, 0, 1, 2, 3, 4, 0]; +const EXPONENTS_FREQ: [i32; 8] = [-2, -1, 0, 0, 1, 2, 3, 4]; +const EXPONENTS_CAP: [i32; 8] = [-12, -11, -10, -9, -8, -7, -6, -5]; +const EXPONENTS_DIODE: [i32; 8] = [-4, 0, 0, 0, 0, 0, 0, 0]; + +#[derive(Debug, Clone, Default)] +struct Es519xxFlags { + is_sign: bool, + is_ol: bool, + is_ul: bool, + is_judge: bool, + is_dc: bool, + is_ac: bool, + is_auto: bool, + is_vahz: bool, + is_voltage: bool, + is_current: bool, + is_micro: bool, + is_milli: bool, + is_resistance: bool, + is_continuity: bool, + is_diode: bool, + is_frequency: bool, + is_duty_cycle: bool, + is_capacitance: bool, + is_temperature: bool, + is_celsius: bool, + is_fahrenheit: bool, +} + +fn parse_flags(buf: &[u8; PACKET_LEN], flags: &mut Es519xxFlags) { + flags.is_judge = buf[7] & (1 << 3) != 0; + flags.is_sign = buf[7] & (1 << 2) != 0; + flags.is_ol = buf[7] & (1 << 0) != 0; + + flags.is_ul = buf[9] & (1 << 3) != 0; + + flags.is_dc = buf[10] & (1 << 3) != 0; + flags.is_ac = buf[10] & (1 << 2) != 0; + flags.is_auto = buf[10] & (1 << 1) != 0; + flags.is_vahz = buf[10] & (1 << 0) != 0; + + match buf[6] { + 0x3b => flags.is_voltage = true, // ';' + 0x3d => { + flags.is_current = true; + flags.is_micro = true; + flags.is_auto = true; + } + 0x3f => { + flags.is_current = true; + flags.is_milli = true; + flags.is_auto = true; + } + 0x30 => { + flags.is_current = true; + flags.is_auto = true; + } + 0x39 => { + flags.is_current = true; + flags.is_auto = false; + } + 0x33 => flags.is_resistance = true, + 0x35 => flags.is_continuity = true, + 0x31 => flags.is_diode = true, + 0x32 => { + if flags.is_judge { + flags.is_duty_cycle = true; + } else { + flags.is_frequency = true; + } + } + 0x36 => flags.is_capacitance = true, + 0x34 => { + flags.is_temperature = true; + // Victor 86E (user report #16): judge set → °F, clear → °C + // (opposite of some ES519xx / libsigrok notes). + if flags.is_judge { + flags.is_fahrenheit = true; + } else { + flags.is_celsius = true; + } + } + _ => {} + } + + if flags.is_vahz && (flags.is_voltage || flags.is_current) { + flags.is_voltage = false; + flags.is_current = false; + flags.is_micro = false; + flags.is_milli = false; + if flags.is_judge { + flags.is_duty_cycle = true; + flags.is_frequency = false; + } else { + flags.is_frequency = true; + flags.is_duty_cycle = false; + } + } +} + +fn flags_valid(flags: &Es519xxFlags) -> bool { + let mult = [flags.is_micro, flags.is_milli] + .into_iter() + .filter(|&b| b) + .count(); + if mult > 1 { + return false; + } + + let modes = [ + flags.is_voltage, + flags.is_current, + flags.is_resistance, + flags.is_frequency, + flags.is_duty_cycle, + flags.is_capacitance, + flags.is_temperature, + flags.is_continuity, + flags.is_diode, + ] + .into_iter() + .filter(|&b| b) + .count(); + if modes > 1 { + return false; + } + + !(flags.is_ac && flags.is_dc) +} + +fn parse_value(buf: &[u8; PACKET_LEN], flags: &Es519xxFlags) -> Option { + // Never return Infinity — graph/histogram binning panics on non-finite samples. + if flags.is_ol || flags.is_ul { + return Some(METER_OVERLOAD_VALUE); + } + + if !buf[1].is_ascii_digit() + || !buf[2].is_ascii_digit() + || !buf[3].is_ascii_digit() + || !buf[4].is_ascii_digit() + || !buf[5].is_ascii_digit() + { + return None; + } + + let mut intval = 0i64; + for &digit in &buf[1..=5] { + intval = intval * 10 + (digit - b'0') as i64; + } + if flags.is_sign { + intval = -intval; + } + Some(intval as f64) +} + +fn range_exponent(buf: &[u8; PACKET_LEN], flags: &Es519xxFlags) -> Option { + let idx = (buf[0] as i32).saturating_sub(b'0' as i32); + if !(0..=7).contains(&idx) { + return None; + } + let idx = idx as usize; + + // Duty / temperature: fixed one-decimal scale (not range tables). + // Temperature digits are always °C on the wire (libsigrok). + Some(if flags.is_duty_cycle || flags.is_temperature { + -1 + } else if flags.is_voltage { + EXPONENTS_VOLTAGE[idx] + } else if flags.is_current && flags.is_micro { + EXPONENTS_UA[idx] + } else if flags.is_current && flags.is_milli { + EXPONENTS_MA[idx] + } else if flags.is_current && flags.is_auto { + EXPONENTS_A[idx] + } else if flags.is_current { + EXPONENTS_MANUAL_A[idx] + } else if flags.is_resistance || flags.is_continuity { + EXPONENTS_RES[idx] + } else if flags.is_frequency { + EXPONENTS_FREQ[idx] + } else if flags.is_capacitance { + EXPONENTS_CAP[idx] + } else if flags.is_diode { + EXPONENTS_DIODE[idx] + } else { + return None; + }) +} + +fn apply_range(buf: &[u8; PACKET_LEN], flags: &Es519xxFlags, value: f64) -> Option { + if value == METER_OVERLOAD_VALUE { + return Some(value); + } + + let exponent = range_exponent(buf, flags)?; + let mut scaled = value * 10f64.powi(exponent); + + // Wire digits are always °C; convert only when unit is Fahrenheit. + if flags.is_temperature && flags.is_fahrenheit { + scaled = scaled * 9.0 / 5.0 + 32.0; + } + + Some(scaled) +} + +/// Mode + unit as on the meter for this range (not SI magnitude auto-pick). +fn mode_and_unit(flags: &Es519xxFlags, exp: i32) -> Option<(MeterMode, &'static str)> { + if flags.is_continuity { + return Some((MeterMode::Cont, "Ohm")); + } + if flags.is_diode { + return Some((MeterMode::Diod, "V")); + } + if flags.is_duty_cycle { + return Some((MeterMode::Duty, "%")); + } + if flags.is_voltage { + // Only the finest voltage ranges are mV (exp -4 / -5); exp -3 is still volts. + if exp <= -4 { + return Some(if flags.is_ac { + (MeterMode::Vac, "mVAC") + } else { + (MeterMode::Vdc, "mVDC") + }); + } + return Some(if flags.is_ac { + (MeterMode::Vac, "VAC") + } else { + (MeterMode::Vdc, "VDC") + }); + } + if flags.is_current { + if flags.is_micro || exp <= -6 { + return Some(if flags.is_ac { + (MeterMode::Aac, "uAAC") + } else { + (MeterMode::Adc, "uADC") + }); + } + if flags.is_milli || exp <= -3 { + return Some(if flags.is_ac { + (MeterMode::Aac, "mAAC") + } else { + (MeterMode::Adc, "mADC") + }); + } + return Some(if flags.is_ac { + (MeterMode::Aac, "AAC") + } else { + (MeterMode::Adc, "ADC") + }); + } + if flags.is_resistance { + // Range exponent → unit annunciator on the meter (for auto-scale off display). + let unit = if exp >= 6 { + "MOhm" + } else if exp >= 1 { + "kOhm" + } else if exp <= -3 { + "mOhm" + } else { + "Ohm" + }; + return Some((MeterMode::Res, unit)); + } + if flags.is_capacitance { + let unit = match exp { + -12 | -11 => "pF", + -10 => "nF", + -9..=-7 => "uF", + -6 | -5 => "mF", + _ => "F", + }; + return Some((MeterMode::Cap, unit)); + } + if flags.is_frequency { + return Some((MeterMode::Freq, "Hz")); + } + if flags.is_temperature { + if flags.is_fahrenheit { + return Some((MeterMode::Temp, "°F")); + } + return Some((MeterMode::Temp, "°C")); + } + None +} + +/// Scale SI value into the unit string from the decoder (meter range unit). +pub fn si_to_meter_unit(value_si: f64, unit: &str) -> f64 { + match unit { + "kOhm" => value_si / 1_000.0, + "MOhm" => value_si / 1_000_000.0, + "mOhm" => value_si * 1_000.0, + "mVDC" | "mVAC" | "mADC" | "mAAC" => value_si * 1_000.0, + "uADC" | "uAAC" | "µADC" | "µAAC" => value_si * 1_000_000.0, + "uF" | "µF" | "μF" => value_si * 1_000_000.0, + "nF" => value_si * 1_000_000_000.0, + "pF" => value_si * 1_000_000_000_000.0, + "mF" => value_si * 1_000.0, + _ => value_si, + } +} + +/// Parse a 14-byte Victor 86E / ES51932 serial frame. +pub fn parse_packet(buf: &[u8; PACKET_LEN]) -> Option { + if buf[12] != b'\r' || buf[13] != b'\n' { + return None; + } + + let mut flags = Es519xxFlags::default(); + parse_flags(buf, &mut flags); + if !flags_valid(&flags) { + return None; + } + + let mut value = parse_value(buf, &flags)?; + let exp = range_exponent(buf, &flags)?; + value = apply_range(buf, &flags, value)?; + + let (mode, unit) = mode_and_unit(&flags, exp)?; + + // Contract: never emit non-finite values to the UI / graph path. + if !value.is_finite() { + value = METER_OVERLOAD_VALUE; + } + + Some(VictorReading { + value, + mode, + unit: unit.to_owned(), + }) +} + +/// Append incoming serial bytes and return any complete parsed frames. +pub fn feed_bytes(buffer: &mut Vec, chunk: &[u8]) -> Vec { + buffer.extend_from_slice(chunk); + let mut readings = Vec::new(); + + loop { + let Some(pos) = buffer.windows(2).position(|w| w == b"\r\n") else { + break; + }; + if pos + 2 < PACKET_LEN { + buffer.drain(..pos + 2); + continue; + } + let start = pos + 2 - PACKET_LEN; + if start > pos { + buffer.drain(..pos + 2); + continue; + } + let end = pos + 2; + let Some(frame_slice) = buffer.get(start..end) else { + buffer.drain(..end); + continue; + }; + let Ok(frame) = <[u8; PACKET_LEN]>::try_from(frame_slice) else { + buffer.drain(..end); + continue; + }; + buffer.drain(..end); + if let Some(reading) = parse_packet(&frame) { + readings.push(reading); + } + } + + if buffer.len() > PACKET_LEN * 4 { + let keep = PACKET_LEN * 2; + let drain_to = buffer.len().saturating_sub(keep); + buffer.drain(..drain_to); + } + + readings +} + +#[cfg(test)] +mod tests { + use super::*; + + fn frame(s: &str) -> [u8; PACKET_LEN] { + let bytes = s.as_bytes(); + assert_eq!(bytes.len(), PACKET_LEN); + bytes.try_into().unwrap() + } + + #[test] + fn parses_86e_dc_voltage_sample() { + // From issue #16: ~3.6 V battery in DC mode. + let reading = parse_packet(&frame("103560;000:3\r\n")).unwrap(); + assert!((reading.value - 3.56).abs() < 1e-6, "got {}", reading.value); + assert_eq!(reading.mode, MeterMode::Vdc); + assert_eq!(reading.unit, "VDC"); + } + + #[test] + fn rejects_incomplete_footer() { + let mut buf = frame("103560;000:3\r\n"); + buf[13] = b'X'; + assert!(parse_packet(&buf).is_none()); + } + + #[test] + fn temperature_celsius_one_decimal() { + // Digits 00316 + exp -1 → 31.6 °C. Judge clear → °C on Victor 86E. + let mut raw = frame("0003164000:3\r\n"); + raw[7] = 0x00; + let reading = parse_packet(&raw).unwrap(); + assert!((reading.value - 31.6).abs() < 1e-6, "got {}", reading.value); + assert_eq!(reading.mode, MeterMode::Temp); + assert_eq!(reading.unit, "°C"); + } + + #[test] + fn temperature_fahrenheit_converts_from_celsius_digits() { + // Judge set → °F on 86E; wire digits still °C → 31.6 * 9/5 + 32 = 88.88. + let mut raw = frame("0003164000:3\r\n"); + raw[7] = 0x08; + let reading = parse_packet(&raw).unwrap(); + assert!( + (reading.value - 88.88).abs() < 1e-3, + "got {}", + reading.value + ); + assert_eq!(reading.mode, MeterMode::Temp); + assert_eq!(reading.unit, "°F"); + } + + #[test] + fn capacitance_microfarad_range() { + // 4.885 µF → SI 4.885e-6 F; unit reflects meter range (uF). + let reading = parse_packet(&frame("3048856000:3\r\n")).unwrap(); + assert!( + (reading.value - 4.885e-6).abs() < 1e-12, + "got {}", + reading.value + ); + assert_eq!(reading.mode, MeterMode::Cap); + assert_eq!(reading.unit, "uF"); + assert!((si_to_meter_unit(reading.value, "uF") - 4.885).abs() < 1e-9); + } + + #[test] + fn resistance_range_selects_kohm_unit() { + // RES range index 5 → exp +3; digits 00047 → 47_000 Ω SI, unit kOhm. + let reading = parse_packet(&frame("5000473000:3\r\n")).unwrap(); + assert!( + (reading.value - 47_000.0).abs() < 1e-6, + "got {}", + reading.value + ); + assert_eq!(reading.mode, MeterMode::Res); + assert_eq!(reading.unit, "kOhm"); + assert!((si_to_meter_unit(reading.value, "kOhm") - 47.0).abs() < 1e-9); + } + + #[test] + fn resistance_exp1_is_kohm_for_meter_unit() { + // exp +1 (range index 3): SI = 47 * 10 = 470 Ω → unit kOhm → 0.47 kΩ display. + let reading = parse_packet(&frame("3000473000:3\r\n")).unwrap(); + assert!( + (reading.value - 470.0).abs() < 1e-9, + "got {}", + reading.value + ); + assert_eq!(reading.unit, "kOhm"); + assert!((si_to_meter_unit(reading.value, "kOhm") - 0.47).abs() < 1e-9); + } + + #[test] + fn overload_is_finite_sentinel_not_infinity() { + // RES function '3', OL status bit 0 — must stay finite (histogram crash). + let mut raw = frame("0000003000:3\r\n"); + raw[7] = 0x01; + let reading = parse_packet(&raw).unwrap(); + assert!(reading.value.is_finite()); + assert_eq!(reading.value, METER_OVERLOAD_VALUE); + assert_eq!(reading.mode, MeterMode::Res); + } + + #[test] + fn feed_bytes_does_not_panic_on_short_crlf() { + let mut buf = Vec::new(); + let readings = feed_bytes(&mut buf, b"\r\n103560;000:3\r\n"); + assert_eq!(readings.len(), 1); + assert!((readings[0].value - 3.56).abs() < 1e-6); + } +} diff --git a/src/victor_fs9922.rs b/src/victor_fs9922.rs new file mode 100644 index 0000000..03d6c87 --- /dev/null +++ b/src/victor_fs9922.rs @@ -0,0 +1,342 @@ +//! Fortune Semiconductor **FS9922-DMM4** — Victor (RuoShui) legacy 86B/C/D USB HID path. +//! +//! Those meters used a USB HID interface (VID 0x1244, PID 0xd237) with obfuscated +//! 14-byte reports from the USB cable. After deobfuscation the payload follows the +//! Fortune Semiconductor FS9922-DMM4 chip format. Read only — modes are set on the meter. +//! +//! **Newer** Victor meters (e.g. 86D, DM1107) do not use this path — they stream a +//! binary serial protocol over an opto-isolated CP2102 link. See `victor_dm1107`. +//! Victor 86E (ES51932 ASCII serial) is a separate line: `victor_es519xx`. +//! +//! ## Sources +//! +//! - +//! - libsigrok `serial_hid_victor.c` and `fs9922.c` + +use crate::multimeter::MeterMode; + +pub const VICTOR_VENDOR_ID: u16 = 0x1244; +pub const VICTOR_PRODUCT_ID: u16 = 0xd237; +pub const PACKET_LEN: usize = 14; + +const OBFUSCATION: [u8; PACKET_LEN] = *b"jodenxunickxia"; +const SHUFFLE: [u8; PACKET_LEN] = [6, 13, 5, 11, 2, 7, 9, 8, 3, 10, 12, 0, 4, 1]; + +#[derive(Debug, Clone, PartialEq)] +pub struct VictorReading { + pub value: f64, + pub mode: MeterMode, + pub unit: String, +} + +#[derive(Debug, Clone, Default)] +struct Fs9922Flags { + is_nano: bool, + is_micro: bool, + is_milli: bool, + is_kilo: bool, + is_mega: bool, + is_volt: bool, + is_ampere: bool, + is_ohm: bool, + is_hertz: bool, + is_farad: bool, + is_celsius: bool, + is_fahrenheit: bool, + is_beep: bool, + is_diode: bool, + is_percent: bool, + is_ac: bool, + is_dc: bool, +} + +fn bit_reverse(b: u8) -> u8 { + const REV_NIBBLE: [u8; 16] = [ + 0x00, 0x08, 0x04, 0x0c, 0x02, 0x0a, 0x06, 0x0e, 0x01, 0x09, 0x05, 0x0d, 0x03, 0x0b, 0x07, + 0x0f, + ]; + REV_NIBBLE[(b >> 4) as usize] | (REV_NIBBLE[(b & 0xf) as usize] << 4) +} + +/// Undo the Victor USB cable's obfuscation to recover the FS9922 packet. +pub fn deobfuscate(raw: &[u8]) -> Option<[u8; PACKET_LEN]> { + if raw.len() != PACKET_LEN { + return None; + } + if raw.iter().all(|&b| b == 0) { + return None; + } + + let mut out = [0u8; PACKET_LEN]; + for (idx, &byte) in raw.iter().enumerate() { + let to_idx = PACKET_LEN - 1 - SHUFFLE[idx] as usize; + out[to_idx] = bit_reverse(byte.wrapping_sub(OBFUSCATION[idx])); + } + Some(out) +} + +/// Extract the 14-byte Victor payload from a HID read buffer. +pub fn extract_packet(buf: &[u8]) -> Option<&[u8; PACKET_LEN]> { + match buf.len() { + PACKET_LEN => Some(buf.try_into().ok()?), + n if n > PACKET_LEN => { + // Some platforms prepend a report ID byte. + if buf[0] == 0 { + buf.get(1..=PACKET_LEN)?.try_into().ok() + } else { + buf.get(..PACKET_LEN)?.try_into().ok() + } + } + _ => None, + } +} + +fn flags_valid(flags: &Fs9922Flags) -> bool { + let mult = [ + flags.is_nano, + flags.is_micro, + flags.is_milli, + flags.is_kilo, + flags.is_mega, + ] + .into_iter() + .filter(|&b| b) + .count(); + if mult > 1 { + return false; + } + + let modes = [ + flags.is_percent, + flags.is_volt, + flags.is_ampere, + flags.is_ohm, + flags.is_hertz, + flags.is_farad, + flags.is_celsius, + flags.is_fahrenheit, + ] + .into_iter() + .filter(|&b| b) + .count(); + if modes > 1 { + return false; + } + + !(flags.is_ac && flags.is_dc || flags.is_celsius && flags.is_fahrenheit) +} + +fn parse_flags(buf: &[u8; PACKET_LEN]) -> Fs9922Flags { + Fs9922Flags { + is_dc: buf[7] & (1 << 4) != 0, + is_ac: buf[7] & (1 << 3) != 0, + is_nano: buf[8] & (1 << 1) != 0, + is_micro: buf[9] & (1 << 7) != 0, + is_milli: buf[9] & (1 << 6) != 0, + is_kilo: buf[9] & (1 << 5) != 0, + is_mega: buf[9] & (1 << 4) != 0, + is_beep: buf[9] & (1 << 3) != 0, + is_diode: buf[9] & (1 << 2) != 0, + is_percent: buf[9] & (1 << 1) != 0, + is_volt: buf[10] & (1 << 7) != 0, + is_ampere: buf[10] & (1 << 6) != 0, + is_ohm: buf[10] & (1 << 5) != 0, + is_hertz: buf[10] & (1 << 3) != 0, + is_farad: buf[10] & (1 << 2) != 0, + is_celsius: buf[10] & (1 << 1) != 0, + is_fahrenheit: buf[10] & (1 << 0) != 0, + } +} + +fn parse_value(buf: &[u8; PACKET_LEN]) -> Option<(f64, i32)> { + let sign = match buf[0] { + b'+' => 1.0, + b'-' => -1.0, + _ => return None, + }; + + if buf[1] == b'?' && buf[2] == b'0' && buf[3] == b':' && buf[4] == b'?' { + return Some((f64::INFINITY, 0)); + } + + if !buf[1].is_ascii_digit() + || !buf[2].is_ascii_digit() + || !buf[3].is_ascii_digit() + || !buf[4].is_ascii_digit() + { + return None; + } + + let intval = (buf[1] - b'0') as i32 * 1000 + + (buf[2] - b'0') as i32 * 100 + + (buf[3] - b'0') as i32 * 10 + + (buf[4] - b'0') as i32; + + let exponent = match buf[6] { + b'0' => 0, + b'1' => -3, + b'2' => -2, + b'4' => -1, + _ => return None, + }; + + Some((sign * intval as f64, exponent)) +} + +fn apply_multipliers(value: f64, exponent: i32, flags: &Fs9922Flags) -> f64 { + let mut exp = exponent; + if flags.is_nano { + exp -= 9; + } + if flags.is_micro { + exp -= 6; + } + if flags.is_milli { + exp -= 3; + } + if flags.is_kilo { + exp += 3; + } + if flags.is_mega { + exp += 6; + } + value * 10f64.powi(exp) +} + +fn mode_from_flags(flags: &Fs9922Flags) -> Option<(MeterMode, &'static str)> { + if flags.is_beep { + return Some((MeterMode::Cont, "Ohm")); + } + if flags.is_diode { + return Some((MeterMode::Diod, "V")); + } + if flags.is_percent { + return Some((MeterMode::Duty, "%")); + } + if flags.is_volt { + if flags.is_ac { + return Some((MeterMode::Vac, "VAC")); + } + return Some((MeterMode::Vdc, "VDC")); + } + if flags.is_ampere { + if flags.is_ac { + return Some((MeterMode::Aac, "AAC")); + } + return Some((MeterMode::Adc, "ADC")); + } + if flags.is_ohm { + return Some((MeterMode::Res, "Ohm")); + } + if flags.is_farad { + return Some((MeterMode::Cap, "F")); + } + if flags.is_hertz { + return Some((MeterMode::Freq, "Hz")); + } + if flags.is_celsius || flags.is_fahrenheit { + return Some((MeterMode::Temp, "°C")); + } + None +} + +/// Parse a deobfuscated FS9922 packet into a measurement reading. +pub fn parse_packet(buf: &[u8; PACKET_LEN]) -> Option { + if buf[12] != b'\r' || buf[13] != b'\n' { + return None; + } + + let flags = parse_flags(buf); + if !flags_valid(&flags) { + return None; + } + + let (mut value, exponent) = parse_value(buf)?; + value = apply_multipliers(value, exponent, &flags); + + if flags.is_beep { + value = if value.is_infinite() { 0.0 } else { 1.0 }; + } + + let (mode, unit) = mode_from_flags(&flags)?; + + Some(VictorReading { + value, + mode, + unit: unit.to_owned(), + }) +} + +/// Process a raw HID read buffer end-to-end. +pub fn parse_hid_buffer(buf: &[u8]) -> Option { + let packet = extract_packet(buf)?; + let deobfuscated = deobfuscate(packet)?; + parse_packet(&deobfuscated) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_all_zero_packet() { + assert!(deobfuscate(&[0u8; PACKET_LEN]).is_none()); + } + + #[test] + fn rejects_invalid_fs9922_footer() { + let mut pkt = [0u8; PACKET_LEN]; + pkt[0] = b'+'; + pkt[1] = b'1'; + pkt[2] = b'2'; + pkt[3] = b'3'; + pkt[4] = b'4'; + pkt[6] = b'4'; + pkt[12] = b'X'; + pkt[13] = b'Y'; + assert!(parse_packet(&pkt).is_none()); + } + + #[test] + fn parses_duty_cycle_reading() { + let mut pkt = [0u8; PACKET_LEN]; + pkt[0] = b'+'; + pkt[1] = b'5'; + pkt[2] = b'2'; + pkt[3] = b'3'; + pkt[4] = b'0'; + pkt[6] = b'2'; // two decimal places -> 52.30 + pkt[9] = 1 << 1; // duty cycle (%) + pkt[12] = b'\r'; + pkt[13] = b'\n'; + + let reading = parse_packet(&pkt).unwrap(); + assert!( + (reading.value - 52.3).abs() < 1e-6, + "expected 52.3, got {}", + reading.value + ); + assert_eq!(reading.mode, MeterMode::Duty); + assert_eq!(reading.unit, "%"); + } + + #[test] + fn parses_simple_voltage_reading() { + let mut pkt = [0u8; PACKET_LEN]; + pkt[0] = b'+'; + pkt[1] = b'1'; + pkt[2] = b'2'; + pkt[3] = b'3'; + pkt[4] = b'4'; + pkt[6] = b'4'; // one decimal place -> 123.4 + pkt[7] = 1 << 4; // DC + pkt[10] = 1 << 7; // volt + pkt[12] = b'\r'; + pkt[13] = b'\n'; + + let reading = parse_packet(&pkt).unwrap(); + assert!((reading.value - 123.4).abs() < f64::EPSILON); + assert_eq!(reading.mode, MeterMode::Vdc); + assert_eq!(reading.unit, "VDC"); + } +}