diff --git a/examples/hx711/main.go b/examples/hx711/main.go new file mode 100644 index 000000000..70fb71a6d --- /dev/null +++ b/examples/hx711/main.go @@ -0,0 +1,74 @@ +package main + +import ( + "machine" + "time" + + "tinygo.org/x/drivers/hx711" +) + +const ( + clockOutPin = machine.D3 + dataInPin = machine.D2 + gainAndChannel = hx711.A128 // only the first channel A is used + tickSleep = 1 * time.Microsecond // set it to zero for slow MCU's + calibrationWait = 10 * time.Second + cycleTime = 1 * time.Second +) + +// please adjust to your load used for calibration +const ( + setLoad = 100 // used unit will equal the measured unit + unit = "gram" +) + +func main() { + time.Sleep(5 * time.Second) // wait for monitor connection + + cfg := hx711.DefaultConfig + cfg.TickSleep = tickSleep + + clockOutPin.Configure(machine.PinConfig{Mode: machine.PinOutput}) + dataInPin.Configure(machine.PinConfig{Mode: machine.PinInputPullup}) + + clockOutPinSet := func(v bool) error { clockOutPin.Set(v); return nil } + dataInPinGet := func() (bool, error) { return dataInPin.Get(), nil } + + sensor := hx711.New(clockOutPinSet, dataInPinGet, gainAndChannel) + if err := sensor.Configure(&cfg); err != nil { + println("Configure failed") + panic(err) + } + + println("Please remove the mass completely for zeroing within", calibrationWait.String()) + time.Sleep(calibrationWait) + println("Zero starts") + if err := sensor.Zero(false); err != nil { + println("Zeroing failed") + panic(err) + } + + println("Please apply the load (", setLoad, unit+" ) for calibration within", calibrationWait.String()) + time.Sleep(calibrationWait) + println("Calibration starts") + if err := sensor.Calibrate(setLoad, false); err != nil { + println("Calibration failed") + panic(err) + } + + offs, factor := sensor.OffsetAndCalibrationFactor(false) + println("Calibration done completely, offset:", offs, "factor:", factor) + + println("Measurement starts") + for { + if err := sensor.Update(0); err != nil { + println("Sensor update failed", err.Error()) + } + + v1, _ := sensor.Values() + + println("Mass:", v1, unit) + + time.Sleep(cycleTime) + } +} diff --git a/fraction.go b/fraction.go new file mode 100644 index 000000000..5a372e375 --- /dev/null +++ b/fraction.go @@ -0,0 +1,71 @@ +package drivers + +import ( + "errors" + "math" +) + +// Float32Fractions calculates an denominator "den" for a given floating point number "f" and returns this denominator +// together with the resulting nominator "nom", so "f" can be reconstructed by "f = num / den". +// All values are in the range MaxInt32: 2147483647, MinInt32: -2147483648. If the given "f" exceeds this range, it is +// not possible anymore to represent "f" with "f = num / 1" and an error will be returned with the nearest values. +// As an exception we define that "den" is always positive, so negative numbers "f" leads always to negative "num". +// +// Sign: +// "abs(MaxInt32) > abs (MinInt32)", the sign can be applied to "den" or "nom", but we already defined "den" as positive +// +// Considered other options: see function in test file +func Float32Fractions(f float32) (int32, int32, error) { + return float32FractionsIntPartBaseMax(f, math.MaxInt32) +} + +// float32FractionsIntPartBaseMax calculates the denominator "den" from the integer part of a given floating point +// number "f" and returns this denominator together with the resulting nominator "nom", so "f" can be reconstructed by +// "f = num / den". +// +// Used formulas: +// For "abs(f)=af < 1" applies the biggest denominator: "den = math.MaxInt32" and "num = af * den". For "af > 0" this +// can be written more generalized when split integer part "ip" from fractional part "fp" with "af = ip + fp": +// "den = math.MaxInt32/(ip + 1)"; "num = af * den" +// very good accuracy can be reached, similar to calculating with "math/big.Rat", but 2-15 times faster: +// max. epsilon = 1.9073486328125e-06 in test for "17459216/697177" on arm64, but better for this example on MCU, +// nrf52840 12.207µs-14.496µs (independent of used base) +func float32FractionsIntPartBaseMax(f float32, baseMax int32) (int32, int32, error) { + //const baseMax = math.MaxInt32 + //const baseMax = 1000000000 // 10 digits + //const baseMax = 2000000000 // 10.5 digits + + ip, den, err := float32FractionsPreCheck(f) + if den == 1 || err != nil { + return ip, den, err + } + + if f < 0 { + ip = -ip + } + + den = baseMax / (ip + 1) + if den == 0 { + den = 1 + } + + return int32(float32(den) * f), den, nil +} + +func float32FractionsPreCheck(f float32) (int32, int32, error) { + if f > math.MaxInt32 { + return math.MaxInt32, 1, errors.New("input value exceeds +int32 range") + } + + if f < math.MinInt32 { + return math.MinInt32, 1, errors.New("input value exceeds -int32 range") + } + + integerPart := int32(f) + if float32(integerPart) == f { + // float is an integer + return int32(integerPart), 1, nil + } + + return integerPart, 0, nil +} diff --git a/fraction_test.go b/fraction_test.go new file mode 100644 index 000000000..ea942fff1 --- /dev/null +++ b/fraction_test.go @@ -0,0 +1,403 @@ +//nolint:funlen // ok for tests +package drivers + +import ( + "errors" + "fmt" + "math" + "math/big" + "sort" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type result struct { + num int32 + den int32 + e float64 +} + +type test struct { + startNum int32 + endNum int32 + startDen int32 + endDen int32 + stepCountNum int32 + stepCountDen int32 + wantErr error + epsilon map[string]float64 // depends on test function + run map[string]bool // depends on test function + useGot64 map[string]bool // depends on test function +} + +type float32FractionsFunc func(f float32) (int32, int32, error) + +const ( + ipB32 = "fract_int_part_base32" + ipB10 = "fract_int_part_base10" + floatMul = "fract_float_multiple" + bigRat = "fract_bigrat" + bigRatString = "fract_bigrat_string_split" + simpleString = "fract_string" +) + +const ( + eSmall = 1e-37 // smallest + e32 = 4.65661287525e-10 // 1/32 bit + e24 = 5.96046483281e-08 // 1/24 bit + e23 = 1.19209303762e-07 // 1/23 bit +) + +func Test_float32Fractions(t *testing.T) { + testFuncs := map[string]float32FractionsFunc{ + ipB32: Float32Fractions, + ipB10: float32FractionsIntPartBase10, + floatMul: float32FractionsManyMult, + bigRat: float32FractionsBigRat, + bigRatString: float32FractionsBigRatStringSplit, + simpleString: float32FractionsString, + } + + tests := map[string]test{ + // 17459216/2147483584 + // 17459216/697177: 1.9073486328125e-06 + //-1610612736/214748365: 4.76837158203125e-07 + // 331725104/174295: 0.0001220703125 + "single_just_for_debug": { + startNum: 17459216, + endNum: 17459216, + startDen: 2147483584, + endDen: 2147483584, + stepCountNum: 1, + stepCountDen: 1, + epsilon: map[string]float64{ + simpleString: e24, + }, + run: map[string]bool{ + ipB32: true, ipB10: true, floatMul: true, bigRat: true, bigRatString: true, simpleString: true, // all + //ipB32: true, simpleString: true, + }, + }, + "plus_inf": { + startNum: 1, + endNum: 1, + startDen: 0, + endDen: 0, + stepCountNum: 1, + stepCountDen: 1, + wantErr: errors.New("input value exceeds +int32 range"), + run: map[string]bool{ + ipB32: true, ipB10: true, floatMul: true, bigRat: true, bigRatString: true, simpleString: true, // all + //ipB32: true, simpleString: true, + }, + }, + "minus_inf": { + startNum: -1, + endNum: -1, + startDen: 0, + endDen: 0, + stepCountNum: 1, + stepCountDen: 1, + wantErr: errors.New("input value exceeds -int32 range"), + run: map[string]bool{ + ipB32: true, ipB10: true, floatMul: true, bigRat: true, bigRatString: true, simpleString: true, // all + //ipB32: true, simpleString: true, + }, + }, + "neg_fast": { + startNum: math.MinInt32, + endNum: 0, + startDen: 1, + endDen: math.MaxInt32, + stepCountNum: 9, + stepCountDen: 15, + epsilon: map[string]float64{ + floatMul: 1e-6, + }, + run: map[string]bool{ + ipB32: true, ipB10: true, floatMul: true, bigRat: true, bigRatString: true, simpleString: true, // all + //ipB32: true, simpleString: true, + }, + useGot64: map[string]bool{ + simpleString: true, + }, + }, + "pos": { + startNum: 0, + endNum: math.MaxInt32, + startDen: 1, + endDen: math.MaxInt32, + stepCountNum: 123, + stepCountDen: 12321, + epsilon: map[string]float64{ + // ipB10: epsilon=-0.0001220703125 for 1763380816/871471 + // floatMul: epsilon=-0.03066958300769329 for 17459216/2081593243 + ipB10: 0.00013, floatMul: 0.031, simpleString: e23, + }, + run: map[string]bool{ + ipB32: true, ipB10: true, floatMul: true, bigRat: true, bigRatString: true, simpleString: true, // all + //ipB32: true, simpleString: true, + }, + useGot64: map[string]bool{ + ipB10: true, simpleString: true, + }, + }, + "pos_high_numbers": { + startNum: math.MaxInt32 - 100, + endNum: math.MaxInt32, + startDen: math.MaxInt32 - 100, + endDen: math.MaxInt32, + stepCountNum: 100, + stepCountDen: 100, + epsilon: map[string]float64{ + //ip: 1e-10, bigRatString: 1e-10, floatMul: 1e-10, + floatMul: 1e-7, + }, + run: map[string]bool{ + ipB32: true, ipB10: true, floatMul: true, bigRat: true, bigRatString: true, simpleString: true, // all + //ipB32: true, simpleString: true, + }, + }, + "pos_very_long_run": { + startNum: 0, + endNum: math.MaxInt32, + startDen: 1, + endDen: math.MaxInt32, + stepCountNum: 123213, + stepCountDen: 123213, + epsilon: map[string]float64{}, + run: map[string]bool{ + //ipB32: true, ipB10: true, floatMul: true, bigRat: true, bigRatString: true, simpleString: true, // all + }, + }, + } + for tfName, testFunc := range testFuncs { + for tcName, tc := range tests { + name := tfName + "_" + tcName + t.Run(name, func(t *testing.T) { + if run, ok := tc.run[tfName]; !ok || !run { + t.Skipf("test case %s skipped intentionally", name) + } + epsilon := eSmall + if e, ok := tc.epsilon[tfName]; ok { + epsilon = e + } + var useGot64 bool + if u, ok := tc.useGot64[tfName]; ok && u { + useGot64 = true + } + + doFractionTest(t, name, tc, testFunc, useGot64, epsilon) + }) + } + } +} + +func doFractionTest(t *testing.T, name string, tc test, testFunc float32FractionsFunc, useGot64 bool, epsilon float64) { + const msgTemplate = "run %s (want: '%s'(%d/%d), %s: '%s'(%d/%d)" + + // arrange + numDelta := (tc.endNum - tc.startNum) / tc.stepCountNum + denDelta := (tc.endDen - tc.startDen) / tc.stepCountDen + var results []result + + den := tc.startDen + stepDen := tc.stepCountDen + + start := time.Now() + for ; stepDen > 0; stepDen-- { + num := tc.startNum + stepNum := tc.stepCountNum + for ; stepNum > 0; stepNum-- { + want := float32(num) / float32(den) + // act + n, d, err := testFunc(want) + // assert + // "float32(float64(num)/float64(den))" is especially needed for functions with base10, e.g. for 34918432/174295 + // which results to '200.341' with (20034099/100000) for "float32(n) / float32(d)" + got := float32(n) / float32(d) + got64 := float32(float64(n) / float64(d)) + if math.Abs(float64(want-got)) > math.Abs(float64(want-got64)) { + if !useGot64 { + msg := fmt.Sprintf(msgTemplate, name, strconv.FormatFloat(float64(want), 'f', -1, 32), num, den, + "got64", strconv.FormatFloat(float64(got64), 'f', -1, 32), n, d) + fmt.Printf(">>> got64 automatically used for %s\n", msg) + } + got = got64 + } + require.Equal(t, tc.wantErr, err, "for run %s", name) + if tc.wantErr == nil { + msg := fmt.Sprintf(msgTemplate, name, strconv.FormatFloat(float64(want), 'f', -1, 32), num, den, + "got", strconv.FormatFloat(float64(got), 'f', -1, 32), n, d) + require.InDelta(t, want, got, epsilon, msg) + } + results = append(results, result{num: num, den: den, e: math.Abs(float64(got - want))}) + + num = num + numDelta + } + den = den + denDelta + } + elapse := time.Since(start) / time.Duration(tc.stepCountNum) / time.Duration(tc.stepCountDen) + sort.Slice(results, func(i, j int) bool { + return results[i].e < results[j].e + }) + + fmin, fcmin := count(results, 0) + fmax, fcmax := count(results, len(results)-1) + fmt.Printf("%s (%d x %s): %d x %v - %d x %v\n", name, len(results), elapse, fcmin, fmin, fcmax, fmax) +} + +func count(s []result, idx int) (result, int) { + res := s[idx] + var count int + for _, v := range s { + if v.e == res.e { + count++ + } + } + + return res, count +} + +// float32FractionsIntPartBase10 uses the integer approach with the base for 10 digits instead of MaxInt32. The results +// for accuracy are poor for special combinations and are equally regarding the speed. +func float32FractionsIntPartBase10(f float32) (int32, int32, error) { + const baseMax = 1000000000 // 10 digits, epsilon=-0.0001220703125 for 1763380816/871471 + //const baseMax = 2000000000 // 10.5 digits, epsilon=1.52587890625e-05 for 87296080/348589 + + return float32FractionsIntPartBaseMax(f, baseMax) +} + +// float32FractionsManyMult takes the common approach for dissolving by count of decimals, but do not use strings. +// This comes with the costs of 10 times float multiplication in maximum. This is like the iterative version of +// float32FractionsIntPartBase10. +// nrf52840: 31.28µs-48.828µs (depends on count of iteration) +func float32FractionsManyMult(f float32) (int32, int32, error) { + if ip, den, err := float32FractionsPreCheck(f); den == 1 || err != nil { + return ip, den, err + } + + const accuracy = 10 + den := float32(1) + num := f + + for i := 0; i < accuracy; i++ { + if num*den == float32(int32(num*den)) { + break + } + + den *= 10.0 + } + + return int32(num * den), int32(den), nil +} + +// float32FractionsBigRat uses the big/math go library for splitting the given value in fractions. This function seems +// to produce more accurate results, but is 5-8 times slower than float32FractionsIntPartBaseMax(MaxInt32). +// nrf52840: 74.768µs-106.049µs +func float32FractionsBigRat(f float32) (int32, int32, error) { + if ip, den, err := float32FractionsPreCheck(f); den == 1 || err != nil { + return ip, den, err + } + + r := new(big.Rat).SetFloat64(float64(f)) + d := r.Denom().Int64() //Denom returns the denominator of x; it is always > 0. + if d > math.MaxInt32 { + d = math.MaxInt32 + } + + n := f * float32(d) + if n > math.MaxInt32 { + println("n ex+", n) + return math.MaxInt32, int32(float32(math.MaxInt32) / f), nil + } else if n < math.MinInt32 { + println("n ex-", n) + return math.MinInt32, int32(float32(math.MinInt32) / f), nil + } + + return int32(n), int32(d), nil +} + +// float32FractionsBigRatStringSplit takes the simplest approach using "big/Rat". +// The accuracy of course is very good, but is 15 times slower than Float32Fractions. +// nrf52840: 225.067µs-334.167µs +func float32FractionsBigRatStringSplit(f float32) (int32, int32, error) { + if ip, den, err := float32FractionsPreCheck(f); den == 1 || err != nil { + return ip, den, err + } + + rat := new(big.Rat).SetFloat64(float64(f)) + s := rat.RatString() + parts := strings.Split(s, "/") + num, err := strconv.Atoi(parts[0]) + if err != nil { + return 0, 0, err + } + + if len(parts) == 1 { + return int32(num), 1, nil + } + + den, err := strconv.Atoi(parts[1]) + if err != nil { + return 0, 0, err + } + + return int32(num), int32(den), nil +} + +// float32FractionsString uses the common method for dissolving by count of decimals. The accuracy is very good, better +// than for some critical values of Float32Fractions, but is 4 times slower than Float32Fractions. +// +// max. epsilon = 1.9073486328125e-06 in test for "17459216/697177" on arm64, but better for this example on MCU, +// +// nrf52840: 55.695µs-83.16µs +func float32FractionsString(f float32) (int32, int32, error) { + // reduce to 9 + 1 digits, according 2147483647 (-2147483648) of 32 bit integer + const accuracy = 9 + ip, den, err := float32FractionsPreCheck(f) + if den == 1 || err != nil { + return ip, den, err + } + + strDecimal := strconv.FormatFloat(float64(f), 'f', -1, 32) + parts := strings.Split(strDecimal, ".") + + if len(parts) < 2 { + //fmt.Errorf("integer detected (%e), pre check was wrong (%d/%d)", f, ip, den) + //fmt.Printf("integer detected (%e), pre check was wrong (%d/%d)\n", f, ip, den) + return int32(f), 1, nil + } + + numeratorStr := parts[0] + parts[1] // "f" without decimal point + // now for 0.0756: part[0]="0", part[1]="0756", numeratorStr="00756" + // now for -0.0756: part[0]="-0", part[1]="0756", numeratorStr="-00756" + + var sign int + if f < 0 { + sign = 1 + } + + if len(numeratorStr) > (accuracy + sign) { + // although when catch-ed by "strconv.FormatFloat" this occurs, but very rare + parts[1] = parts[1][:(accuracy+sign)-len(parts[0])] + numeratorStr = parts[0] + parts[1] + } + + num, err := strconv.Atoi(numeratorStr) // ignores leading zeros automatically and considers the sign + if err != nil { + return int32(f), 1, errors.New(numeratorStr + " for numerator is no integer") + } + + den = 1 + for i := 0; i < len(parts[1]); i++ { + den *= 10 + } + // for 0.0756: num=756; for -0.0756: num=-756, den=10000 (len(parts[1])=4) + + return int32(num), den, nil +} diff --git a/go.mod b/go.mod index 10c3e98a7..671671893 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,9 @@ module tinygo.org/x/drivers - go 1.22.1 toolchain go1.23.1 - require ( github.com/eclipse/paho.mqtt.golang v1.2.0 github.com/frankban/quicktest v1.10.2 @@ -19,7 +17,11 @@ require ( ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/kr/pretty v0.2.1 // indirect github.com/kr/text v0.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/testify v1.11.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 6bb35574d..0ca5f9411 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,6 @@ github.com/bgould/http v0.0.0-20190627042742-d268792bdee7/go.mod h1:BTqvVegvwifopl4KTEDth6Zezs9eR+lCWhvGKvkxJHE= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/eclipse/paho.mqtt.golang v1.2.0 h1:1F8mhG9+aO5/xpdtFkW4SxOJB67ukuDC3t2y2qayIX0= github.com/eclipse/paho.mqtt.golang v1.2.0/go.mod h1:H9keYFcgq3Qr5OUJm/JZI/i6U7joQ8SYLhZwfeOo6Ts= github.com/frankban/quicktest v1.10.2 h1:19ARM85nVi4xH7xPXuc5eM/udya5ieh7b/Sv+d844Tk= @@ -15,14 +17,21 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/orsinium-labs/tinymath v1.1.0 h1:KomdsyLHB7vE3f1nRAJF2dyf1m/gnM2HxfTeV1vS5UA= github.com/orsinium-labs/tinymath v1.1.0/go.mod h1:WPXX6ei3KSXG7JfA03a+ekCYaY9SWN4I+JRl2p6ck+A= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/soypat/natiu-mqtt v0.5.1 h1:rwaDmlvjzD2+3MCOjMZc4QEkDkNwDzbct2TJbpz+TPc= github.com/soypat/natiu-mqtt v0.5.1/go.mod h1:xEta+cwop9izVCW7xOx2W+ct9PRMqr0gNVkvBPnQTc4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/valyala/fastjson v1.6.3/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d h1:0olWaB5pg3+oychR51GUVCEsGkeCU/2JxjBgIo4f3M0= golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= tinygo.org/x/drivers v0.14.0/go.mod h1:uT2svMq3EpBZpKkGO+NQHjxjGf1f42ra4OnMMwQL2aI= tinygo.org/x/drivers v0.15.1/go.mod h1:uT2svMq3EpBZpKkGO+NQHjxjGf1f42ra4OnMMwQL2aI= tinygo.org/x/tinyfont v0.2.1/go.mod h1:eLqnYSrFRjt5STxWaMeOWJTzrKhXqpWw7nU3bPfKOAM= diff --git a/hx711/hx711.go b/hx711/hx711.go new file mode 100644 index 000000000..ccd710429 --- /dev/null +++ b/hx711/hx711.go @@ -0,0 +1,387 @@ +// Package hx711 provides a driver for the HX711 24 bit, 2 channel, configurable ADC with serial output to measure small +// differential voltages. The device is handy for load cells but can be used to read all kind of Wheatstone bridges. +// Therefore the usage of the phrases "mass", "weight" or "load" are prevented in this driver - "value" is used instead. +// +// Datasheet: https://cdn.sparkfun.com/datasheets/Sensors/ForceFlex/hx711_english.pdf +package hx711 + +import ( + "errors" + "strconv" + "sync" + "time" + + "tinygo.org/x/drivers" +) + +type GainAndChannelCfg int + +const ( + None GainAndChannelCfg = 0 // channel is not used + A128 GainAndChannelCfg = 1 // channel A, gain factor 128 - 25 pulses + B32 GainAndChannelCfg = 2 // channel B, gain factor 32 - 26 pulses + A64 GainAndChannelCfg = 3 // channel A, gain factor 64 - 27 pulses + A128B32 GainAndChannelCfg = 4 // channel A@128 and channel B@32 - after first read 26 pulses, after second 25 pulses + A64B32 GainAndChannelCfg = 5 // channel A@64 and channel B@32 - after first read 26 pulses, after second 27 pulses +) + +const ( + minResetDuration = 60 * time.Microsecond + readCycleTime = 100 * time.Millisecond // for RATE=0 (10Hz), otherwise 12.5 ms (80Hz) +) + +type DeviceConfig struct { + TickSleep time.Duration // duration for H-part in the L-H-L pulse, see remarks in the default config below + ResetDuration time.Duration // must not be smaller than 60 us + SettlingTime time.Duration + ReadTimeout time.Duration + ReadTriesMax uint8 // how often a check for ready state is done until the read timeout is reached +} + +var DefaultConfig = DeviceConfig{ + // setting "tickSleep" to a value bigger than 0 is needed for fast MCUs, typically 1 us is needed + // the HX711 works between 0.2 and 50 us pulse wide according the data sheet + // e.g. for the nRF52840 setting this to 0 leads to a pulse wide of around 1 us, which is fine, but setting it + // to 1 us leads to a pulse wide of 20 us, which also will work but slows down unnecessary + TickSleep: 0, + ResetDuration: 100 * time.Microsecond, + SettlingTime: 400 * time.Millisecond, + ReadTimeout: 2 * time.Second, + ReadTriesMax: 100, +} + +type readingConfig struct { + gainAndChanAfterRead GainAndChannelCfg + // offset and calibrationFactor will be used to adjust measures, we use this formulas: + // * y = m*(x+n/m); n/m=offset; m=calibrationFactor + // used for calibration: + // * offset = y/m-x; offset=-x @ y=0 + // * m=y/(x+offset); y=setWeight + offset int32 + calibrationFactor float32 +} + +// Device contains attributes for reading the values of HX711. +type Device struct { + // powerDownAndSckPin is connected to the PD_SCK pin of the HX711, 25-27 pulses can be given + // pulses are typically L-H-L for 1us (0.2..50us), when this pin is low, the chip is in normal operating mode + // if clock stays more than 60us at high, the chip enters power down mode + // 25 pulses: read 24 bits from the input with gain which was former selected, select A@128 for next read + // 26 pulses: read 24 bits from the input with gain which was former selected, select B@32 for next read + // 27 pulses: read 24 bits from the input with gain which was former selected, select A@64 for next read + // note: after reset the input A with gain 128 is selected for next read (the first read is maybe not what you need) + powerDownAndSckPin drivers.PinOutput + // dataPin is connected to the data output pin of the HX711, the 24 bits of data will be shifted out with MSB first + // if the pin is high, the chip is not ready for data, e.g. after the 25th pulse or in power down mode + // the pin should be configured as pull up or with an external pull up resistor - means not ready by default + dataPin drivers.PinInput + // device configuration options + devCfg DeviceConfig + // configuration of readings + waitDuration time.Duration + firstReadingCfg readingConfig + secondReadingCfg readingConfig + // synchronization + mu *sync.Mutex + // last stored value + firstRawValue int32 // can be the value from channel A or B (if only B was read) + secondRawValue int32 +} + +// New returns a device for reading differential voltages with 2 inputs (A, B). The gain of input A can be chosen +// between 128 (default) and 64 - the gain of input B is always 32. +// The reading can be chosen between: +// * A@128 only +// * A@64 only +// * B@32 only +// * A@128 followed by B@32 +// * A@64 followed by B@32 +// The given pins needs to be already configured at caller side like this for TinyGo MCUs: +// powerDownAndSckPin.Configure(machine.PinConfig{Mode: machine.PinOutput}) +// dataPin.Configure(machine.PinConfig{Mode: machine.PinInputPullup}) +func New(powerDownAndSckPin drivers.PinOutput, dataPin drivers.PinInput, gc GainAndChannelCfg) *Device { + gc1, gc2 := gc.splitGainAndChannelConfig() + rc1 := readingConfig{gainAndChanAfterRead: gc1, calibrationFactor: 1} + rc2 := readingConfig{gainAndChanAfterRead: gc2, calibrationFactor: 1} + + d := Device{ + powerDownAndSckPin: powerDownAndSckPin, + dataPin: dataPin, + devCfg: DefaultConfig, + firstReadingCfg: rc1, + secondReadingCfg: rc2, + mu: &sync.Mutex{}, + } + + return &d +} + +// Configure configures initially the driver +func (d *Device) Configure(cfg *DeviceConfig) error { + d.mu.Lock() + defer d.mu.Unlock() + + if cfg != nil { + d.devCfg = *cfg + } + + if d.devCfg.ResetDuration < minResetDuration { + println("adapt reset duration to minimum required", minResetDuration.String()) + d.devCfg.ResetDuration = minResetDuration + } + + if d.devCfg.ReadTriesMax < 1 { + println("adapt maximum read tries to 1") + d.devCfg.ReadTriesMax = 1 + } + + d.waitDuration = d.devCfg.ReadTimeout / time.Duration(d.devCfg.ReadTriesMax) + + return d.reset() +} + +// Zero sets the offset for the reading. If the given flag is true, this is done for the second reading. +func (d *Device) Zero(secondReading bool) error { + d.mu.Lock() + defer d.mu.Unlock() + + rc := &d.firstReadingCfg + if secondReading { + if d.secondReadingCfg.gainAndChanAfterRead == None { + return errors.New("no zero possible, second reading is not configured") + } + + rc = &d.secondReadingCfg + } + + rc.set(0, 1) + + v, v2, err := d.readChannelsWithTimout() + if err != nil { + return err + } + + if secondReading { + v = v2 + } + + rc.set(-v, 1) + + return nil +} + +// Calibrate calculates, after a measurement of the set value is done, a factor for linear scaling the values of the +// subsequent measurements. The unit of the given set value define the unit of the measurement result later. Before +// using this function, the offset value should be obtained by calling Zero() function with no load. +// If the given flag is true, this is done for the second reading. +func (d *Device) Calibrate(setValue int32, secondReading bool) error { + d.mu.Lock() + defer d.mu.Unlock() + + rc := &d.firstReadingCfg + if secondReading { + if d.secondReadingCfg.gainAndChanAfterRead == None { + return errors.New("no calibration possible, second reading is not configured") + } + + rc = &d.secondReadingCfg + } + + v, v2, err := d.readChannelsWithTimout() + if err != nil { + return err + } + + if secondReading { + v = v2 + } + + return rc.calculateAndSetCalibrationFactor(setValue, v) +} + +// Update implements the drivers.Sensor interface +func (d *Device) Update(drivers.Measurement) error { + d.mu.Lock() + defer d.mu.Unlock() + + v1, v2, err := d.readChannelsWithTimout() + if err != nil { + return err + } + + d.firstRawValue = v1 + d.secondRawValue = v2 + + return nil +} + +// Values returns both scaled values from the last successful update +func (d *Device) Values() (int64, int64) { + d.mu.Lock() + defer d.mu.Unlock() + + return d.firstReadingCfg.scale(d.firstRawValue), d.secondReadingCfg.scale(d.secondRawValue) +} + +// OffsetAndCalibrationFactor returns linear correction values, used for reading. +// If the given flag is true, this values are related to the second reading. +func (d *Device) OffsetAndCalibrationFactor(secondReading bool) (int32, float32) { + d.mu.Lock() + defer d.mu.Unlock() + + rc := d.firstReadingCfg + if secondReading { + rc = d.secondReadingCfg + } + + return rc.get() +} + +// SetOffsetAndCalibrationFactor sets linear correction values, used for reading. +// If the given flag is true, this values are related to the second reading. +func (d *Device) SetOffsetAndCalibrationFactor(offset int32, calibrationFactor float32, secondReading bool) { + d.mu.Lock() + defer d.mu.Unlock() + + rc := &d.firstReadingCfg + if secondReading { + rc = &d.secondReadingCfg + } + + rc.set(offset, calibrationFactor) +} + +func (d *Device) reset() error { + // set clock pin to high for >60us to enter power down mode (reset) + d.powerDownAndSckPin.Set(true) + time.Sleep(d.devCfg.ResetDuration) + // set the clock pin back to low to enter normal operating mode + d.powerDownAndSckPin.Set(false) + time.Sleep(d.devCfg.SettlingTime) + // make a first read, to apply the channel and gain configuration for the subsequent reads + _, _, err := d.readChannelsWithTimout() + + return err +} + +// readChannelsWithTimout performs a single read of both channels. If only the first reading is configured, only one +// reading is performed for the configured channel and the second return value will be zero. +func (d *Device) readChannelsWithTimout() (int32, int32, error) { + v1, err := d.readWithTimout(d.firstReadingCfg.gainAndChanAfterRead) + if d.secondReadingCfg.gainAndChanAfterRead == None { + return v1, 0, err + } + + v2, err := d.readWithTimout(d.secondReadingCfg.gainAndChanAfterRead) + + return v1, v2, err +} + +// readWithTimout waits for the device to be ready, reads the configured count of bits from the configured channel and +// returns it converted to an integer value. If the device is not ready in time, an error will be returned. +func (d *Device) readWithTimout(gc GainAndChannelCfg) (int32, error) { + retries := d.devCfg.ReadTriesMax + + for retries > 0 { + if busy := d.dataPin.Get(); !busy { + return d.read(gc), nil + } + time.Sleep(d.waitDuration) + retries-- + } + + return 0, errors.New("timeout reached for HX711 on wait for ready state (" + strconv.Itoa(int(gc)) + ")") +} + +// read reads the 24 bits serially with the configured count of ticks from the configured channel and returns the +// bits converted to an integer value. +func (d *Device) read(gc GainAndChannelCfg) int32 { + var value int32 + var bitSet bool + + for i := 0; i < 24; i++ { + d.tick() + value = value << 1 + bitSet = d.dataPin.Get() + if bitSet { + value = value | 1 + } + } + + // write gain and channel + for i := 0; i < int(gc); i++ { + d.tick() + } + + //nolint:mnd // ok here + value = (value << 8) >> 8 // ensure leading ones for negative value + + return value +} + +// tick creates a (L-)H-L pulse on the clock pin. For fast devices a configurable sleep is used. +func (d *Device) tick() { + d.powerDownAndSckPin.Set(true) + time.Sleep(d.devCfg.TickSleep) + d.powerDownAndSckPin.Set(false) + time.Sleep(d.devCfg.TickSleep) +} + +// set sets a new offset and calibration factor to the configuration for reading +func (rc *readingConfig) set(o int32, c float32) { + rc.offset = o + rc.calibrationFactor = c +} + +// get gets the configured offset and calibration factor for reading +func (rc *readingConfig) get() (int32, float32) { + return rc.offset, rc.calibrationFactor +} + +// calculateAndSetCalibrationFactor calculates the calibration factor from the difference between given set value and +// current value from a measurement +func (rc *readingConfig) calculateAndSetCalibrationFactor(setValue, currentRawValue int32) error { + if setValue == 0 { + return errors.New("set value needs to be <> 0") + } + + if currentRawValue == 0 { + return errors.New("read value is exactly 0") + } + + newCF := float32(setValue) / float32(currentRawValue+rc.offset) + // newCF can never become zero for 24 bit measure: 1/(8388607+2147483647) = ~4.6e-10 > min float32 (~1.4e-45) + rc.calibrationFactor = newCF + + return nil +} + +// scale adjust the given measurement value by the offset and calibration factor +func (rc *readingConfig) scale(v int32) int64 { + return int64(float32(v+rc.offset) * rc.calibrationFactor) +} + +// ensure limit, splits and returns the config values to first and second reading +func (gc GainAndChannelCfg) splitGainAndChannelConfig() (GainAndChannelCfg, GainAndChannelCfg) { + if gc < A128 { + gc = A128 + } + + if gc > A64B32 { + gc = A64B32 + } + + gc1 := gc + gc2 := None + //nolint:exhaustive // ok here + switch gc { + case A128B32: + gc1 = B32 + gc2 = A128 + case A64B32: + gc1 = B32 + gc2 = A64 + } + + return gc1, gc2 +} diff --git a/hx711/hx711_test.go b/hx711/hx711_test.go new file mode 100644 index 000000000..a479acc12 --- /dev/null +++ b/hx711/hx711_test.go @@ -0,0 +1,609 @@ +//nolint:funlen // ok for tests +package hx711 + +import ( + "errors" + "math" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "tinygo.org/x/drivers" +) + +//nolint:gochecknoglobals // ok for test +var ( + // to speed up tests + fastCfg = DeviceConfig{ + ResetDuration: 1 * time.Nanosecond, + SettlingTime: 2 * time.Nanosecond, + ReadTimeout: 3 * time.Nanosecond, + ReadTriesMax: 2, + } + + getValuesZero = [24]bool{ + false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, + } + + // first value is ready state + readyValuesZero = append([]bool{false}, getValuesZero[:]...) + // first value simulates busy-state + busyReadyValuesZero = append([]bool{true}, readyValuesZero...) + + // 123dec, 0x7B, 0111 1011, MSB first needed + getValues123 = [24]bool{ + false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, + false, true, true, true, true, false, true, true, + } + + // 7654321, 0x74CBB1, 0111 0100 1100 1011 1011 0001, MSB first + getValues7654321 = [24]bool{ + false, true, true, true, false, true, false, false, + true, true, false, false, true, false, true, true, + true, false, true, true, false, false, false, true, + } + + // the maximum for 24 bit: 0x7FFFFF, 8388607 + getValues4bitMax = [24]bool{ + false, true, true, true, true, true, true, true, + true, true, true, true, true, true, true, true, + true, true, true, true, true, true, true, true, + } + + // first value is ready state + readyValues123 = append([]bool{false}, getValues123[:]...) + readyValues7654321 = append([]bool{false}, getValues7654321[:]...) + readyValues24bitMax = append([]bool{false}, getValues4bitMax[:]...) + + clkReset = []bool{true, false} + clk24BitData = [48]bool{ + true, false, true, false, true, false, true, false, true, false, true, false, + true, false, true, false, true, false, true, false, true, false, true, false, + true, false, true, false, true, false, true, false, true, false, true, false, + true, false, true, false, true, false, true, false, true, false, true, false, + } + + // last pulse for A@128 selection + clkDataA128 = append(clk24BitData[:], true, false) + // last pulses for B@32 selection + clkDataB32 = append(clk24BitData[:], true, false, true, false) + // last pulses for A@64 selection + clkDataA64 = append(clk24BitData[:], true, false, true, false, true, false) +) + +func TestNew(t *testing.T) { + // arrange + pClkMock := outputPinMock{} + pClk := newOutPin(&pClkMock) + pDtaMock := inputPinMock{} + pDta := newInPin(&pDtaMock) + // act + d := New(pClk, pDta, A128) + // assert + assert.IsType(t, &Device{}, d) + assert.NotNil(t, d.powerDownAndSckPin) + assert.NotNil(t, d.dataPin) + assert.NotNil(t, d.firstReadingCfg) + assert.Equal(t, A128, d.firstReadingCfg.gainAndChanAfterRead) + assert.Equal(t, int32(0), d.firstReadingCfg.offset) + assert.InDelta(t, float32(1), d.firstReadingCfg.calibrationFactor, 0) + assert.NotNil(t, d.secondReadingCfg) + assert.Equal(t, None, d.secondReadingCfg.gainAndChanAfterRead) + assert.Equal(t, int32(0), d.secondReadingCfg.offset) + assert.InDelta(t, float32(1), d.secondReadingCfg.calibrationFactor, 0) + assert.NotNil(t, d.mu) + assert.NotNil(t, d.devCfg) + assert.Equal(t, DefaultConfig, d.devCfg) +} + +func TestNew_gainAndChannelCfg(t *testing.T) { + tests := map[string]struct { + simGainAndChannelCfg GainAndChannelCfg + wantFirstReadingCfg readingConfig + wantSecondReadingCfg readingConfig + }{ + "new_none_is_a128": { + simGainAndChannelCfg: None, + wantFirstReadingCfg: readingConfig{gainAndChanAfterRead: A128, calibrationFactor: 1}, + wantSecondReadingCfg: readingConfig{gainAndChanAfterRead: None, calibrationFactor: 1}, + }, + "new_a128": { + simGainAndChannelCfg: A128, + wantFirstReadingCfg: readingConfig{gainAndChanAfterRead: A128, calibrationFactor: 1}, + wantSecondReadingCfg: readingConfig{gainAndChanAfterRead: None, calibrationFactor: 1}, + }, + "new_a64": { + simGainAndChannelCfg: A64, + wantFirstReadingCfg: readingConfig{gainAndChanAfterRead: A64, calibrationFactor: 1}, + wantSecondReadingCfg: readingConfig{gainAndChanAfterRead: None, calibrationFactor: 1}, + }, + "new_b32": { + simGainAndChannelCfg: B32, + wantFirstReadingCfg: readingConfig{gainAndChanAfterRead: B32, calibrationFactor: 1}, + wantSecondReadingCfg: readingConfig{gainAndChanAfterRead: None, calibrationFactor: 1}, + }, + "new_a128b32": { + simGainAndChannelCfg: A128B32, + wantFirstReadingCfg: readingConfig{gainAndChanAfterRead: B32, calibrationFactor: 1}, + wantSecondReadingCfg: readingConfig{gainAndChanAfterRead: A128, calibrationFactor: 1}, + }, + "new_a64b32": { + simGainAndChannelCfg: A64B32, + wantFirstReadingCfg: readingConfig{gainAndChanAfterRead: B32, calibrationFactor: 1}, + wantSecondReadingCfg: readingConfig{gainAndChanAfterRead: A64, calibrationFactor: 1}, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // arrange + pClkMock := outputPinMock{} + pClk := newOutPin(&pClkMock) + pDtaMock := inputPinMock{} + pDta := newInPin(&pDtaMock) + // act + d := New(pClk, pDta, tc.simGainAndChannelCfg) + // assert + assert.Equal(t, tc.wantFirstReadingCfg, d.firstReadingCfg) + assert.Equal(t, tc.wantSecondReadingCfg, d.secondReadingCfg) + }) + } +} + +func TestConfigure(t *testing.T) { + const tries = 5 + devCfg := DeviceConfig{ + ResetDuration: minResetDuration, // to prevent messages + SettlingTime: 2 * time.Nanosecond, + ReadTimeout: 3 * time.Nanosecond, + ReadTriesMax: tries, + } + + tests := map[string]struct { + simDevConfig *DeviceConfig + simGainAndChannelCfg GainAndChannelCfg + simPinDtaGetValues []bool + simClkPinSetErr_ error + simDtaPinGetErr_ error + wantDevConfig DeviceConfig + wantPinClkSetCalled []bool + wantErr string + }{ + "configure_none_is_a128": { + simDevConfig: &devCfg, + simGainAndChannelCfg: None, + simPinDtaGetValues: busyReadyValuesZero, + wantDevConfig: devCfg, + wantPinClkSetCalled: append(clkReset, clkDataA128...), + }, + "configure_a128": { + simDevConfig: &devCfg, + simGainAndChannelCfg: A128, + simPinDtaGetValues: busyReadyValuesZero, + wantDevConfig: devCfg, + wantPinClkSetCalled: append(clkReset, clkDataA128...), + }, + "configure_b32": { + simDevConfig: &devCfg, + simGainAndChannelCfg: B32, + simPinDtaGetValues: busyReadyValuesZero, + wantDevConfig: devCfg, + wantPinClkSetCalled: append(clkReset, clkDataB32...), + }, + "configure_a64_keep_default_config": { + simDevConfig: nil, + simGainAndChannelCfg: A64, + simPinDtaGetValues: busyReadyValuesZero, + wantDevConfig: DefaultConfig, + wantPinClkSetCalled: append(clkReset, clkDataA64...), + }, + "configure_a128b32_with_adjustement": { + simDevConfig: &DeviceConfig{ + ResetDuration: 59 * time.Microsecond, // forces adjustment + SettlingTime: 1 * time.Nanosecond, + ReadTimeout: 2 * time.Nanosecond, + ReadTriesMax: 0, // forces adjustment + }, + simGainAndChannelCfg: A128B32, + // must contain no busy state, because only 1 try is allowed + simPinDtaGetValues: append(readyValuesZero, readyValuesZero...), + wantDevConfig: DeviceConfig{ + ResetDuration: minResetDuration, + SettlingTime: 1 * time.Nanosecond, + ReadTimeout: 2 * time.Nanosecond, + ReadTriesMax: 1, + }, + wantPinClkSetCalled: append(append(clkReset, clkDataB32...), clkDataA128...), + }, + "configure_a64b32": { + simDevConfig: &devCfg, + simGainAndChannelCfg: A64B32, + simPinDtaGetValues: append(busyReadyValuesZero, readyValuesZero...), + wantDevConfig: devCfg, + wantPinClkSetCalled: append(append(clkReset, clkDataB32...), clkDataA64...), + }, + /* + "error_set_clk_pin": { + simDevConfig: &devCfg, + simClkPinSetErr: errors.New("set clk err"), + wantDevConfig: devCfg, + wantPinClkSetCalled: []bool{true}, + wantErr: "set clk err", + }, + */ + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // arrange + pClkMock := outputPinMock{ + //setErr: tc.simClkPinSetErr, + } + pClk := newOutPin(&pClkMock) + pDtaMock := inputPinMock{ + getSimReturn: tc.simPinDtaGetValues, + //getErr: tc.simDtaPinGetErr, + } + pDta := newInPin(&pDtaMock) + d := New(pClk, pDta, tc.simGainAndChannelCfg) + // act + err := d.Configure(tc.simDevConfig) + // assert + if tc.wantErr != "" { + require.EqualError(t, err, tc.wantErr) + } else { + require.NoError(t, err) + } + assert.Equal(t, tc.wantDevConfig, d.devCfg) + assert.Equal(t, tc.wantPinClkSetCalled, pClkMock.setCalled) + assert.Equal(t, len(tc.simPinDtaGetValues), pDtaMock.getCalled) + }) + } +} + +func TestZero(t *testing.T) { + tests := map[string]struct { + secondReading bool + simGainAndChannelCfg GainAndChannelCfg + simPinDtaGetValues []bool + //simClkPinSetErr error + wantFirstReadingOffs int32 + wantSecondReadingOffs int32 + wantErr string + }{ + "zero_first": { + simGainAndChannelCfg: A128, + simPinDtaGetValues: readyValues123, + wantFirstReadingOffs: -123, + wantSecondReadingOffs: 0, + }, + "zero_second": { + secondReading: true, + simGainAndChannelCfg: A128B32, + simPinDtaGetValues: append(readyValues123, readyValues7654321...), + wantFirstReadingOffs: 0, + wantSecondReadingOffs: -7654321, + }, + "error_zero_no_second": { + secondReading: true, + simGainAndChannelCfg: A128, + wantErr: "no zero possible, second reading is not configured", + }, + "error_get_dta_timeout": { + simPinDtaGetValues: []bool{true, true}, + wantErr: "timeout reached for HX711 on wait for ready state (1)", + }, + /* + "error_zero_read": { + simPinDtaGetValues: []bool{false}, + //simClkPinSetErr: errors.New("set clk on zero err"), + simGainAndChannelCfg: A128, + wantErr: "set clk on zero err", + }, + */ + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // arrange + pClkMock := outputPinMock{ + //setErr: tc.simClkPinSetErr, + } + pClk := newOutPin(&pClkMock) + pDtaMock := inputPinMock{getSimReturn: tc.simPinDtaGetValues} + pDta := newInPin(&pDtaMock) + d := New(pClk, pDta, tc.simGainAndChannelCfg) + d.devCfg = fastCfg + // act + err := d.Zero(tc.secondReading) + // assert + if tc.wantErr != "" { + require.EqualError(t, err, tc.wantErr) + } else { + require.NoError(t, err) + } + assert.Equal(t, len(tc.simPinDtaGetValues), pDtaMock.getCalled) + assert.Equal(t, tc.wantFirstReadingOffs, d.firstReadingCfg.offset) + assert.InDelta(t, 1, d.firstReadingCfg.calibrationFactor, 0) + assert.Equal(t, tc.wantSecondReadingOffs, d.secondReadingCfg.offset) + assert.InDelta(t, 1, d.secondReadingCfg.calibrationFactor, 0) + }) + } +} + +func TestCalibrate(t *testing.T) { + tests := map[string]struct { + setValue int32 + secondReading bool + simGainAndChannelCfg GainAndChannelCfg + simPinDtaGetValues []bool + simOffset int32 + //simClkPinSetErr error + wantFirstReadingCalFac float32 + wantSecondReadingCalFac float32 + wantErr string + }{ + "calibrate_first": { + setValue: 123 * 2, + simGainAndChannelCfg: A128, + simPinDtaGetValues: readyValues123, + wantFirstReadingCalFac: 2, + wantSecondReadingCalFac: 1, + }, + "calibrate_second": { + setValue: 7654321 * 3, + secondReading: true, + simGainAndChannelCfg: A128B32, + simPinDtaGetValues: append(readyValues123, readyValues7654321...), + wantFirstReadingCalFac: 1, + wantSecondReadingCalFac: 3, + }, + "error_calibrate_no_second": { + secondReading: true, + simGainAndChannelCfg: A128, + wantFirstReadingCalFac: 1, + wantSecondReadingCalFac: 1, + wantErr: "no calibration possible, second reading is not configured", + }, + /* + "error_calibrate_read": { + simPinDtaGetValues: []bool{false}, + simClkPinSetErr: errors.New("set clk on calibrate err"), + simGainAndChannelCfg: A128, + wantFirstReadingCalFac: 1, + wantSecondReadingCalFac: 1, + wantErr: "set clk on calibrate err", + }, + */ + "error_setvalue_zero": { + setValue: 0, + simGainAndChannelCfg: A128, + simPinDtaGetValues: readyValues123, + wantFirstReadingCalFac: 1, + wantSecondReadingCalFac: 1, + wantErr: "set value needs to be <> 0", + }, + "error_read_exactly_zero": { + setValue: 1, + simGainAndChannelCfg: A128, + simPinDtaGetValues: readyValuesZero, + wantFirstReadingCalFac: 1, + wantSecondReadingCalFac: 1, + wantErr: "read value is exactly 0", + }, + "error_result_exactly_zero": { + // this can happen, if the set value is much smaller than the read value, practically this can be prevented by + // selecting another unit for the set value (and measurement), e.g. use 0.1 gram instead of 1e-07 tons + setValue: 1, + simGainAndChannelCfg: A128, + simPinDtaGetValues: readyValues24bitMax, + simOffset: math.MaxInt32, + wantFirstReadingCalFac: -4.6748744e-10, + wantSecondReadingCalFac: 1, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // arrange + pClkMock := outputPinMock{ + // setErr: tc.simClkPinSetErr, + } + pClk := newOutPin(&pClkMock) + pDtaMock := inputPinMock{getSimReturn: tc.simPinDtaGetValues} + pDta := newInPin(&pDtaMock) + d := New(pClk, pDta, tc.simGainAndChannelCfg) + d.devCfg = fastCfg + d.firstReadingCfg.offset = tc.simOffset + // act + err := d.Calibrate(tc.setValue, tc.secondReading) + // assert + if tc.wantErr != "" { + require.EqualError(t, err, tc.wantErr) + } else { + require.NoError(t, err) + } + assert.Equal(t, len(tc.simPinDtaGetValues), pDtaMock.getCalled) + assert.InDelta(t, tc.wantFirstReadingCalFac, d.firstReadingCfg.calibrationFactor, 0.00001) + assert.InDelta(t, tc.wantSecondReadingCalFac, d.secondReadingCfg.calibrationFactor, 0.00001) + }) + } +} + +func TestUpdate_Values(t *testing.T) { + tests := map[string]struct { + simGainAndChannelCfg GainAndChannelCfg + simPinDtaGetValues []bool + //simClkPinSetErr error + wantFirstReadingRaw float32 + wantSecondReadingRaw float32 + wantFirstReading float32 + wantSecondReading float32 + wantErr string + }{ + "update_single": { + simGainAndChannelCfg: A128, + simPinDtaGetValues: readyValues123, + wantFirstReadingRaw: 123, + wantFirstReading: (123 - 5000) * 2, + wantSecondReading: 4000, // because we set the offset to 1000 below + }, + "update_double": { + simGainAndChannelCfg: A128B32, + simPinDtaGetValues: append(readyValues7654321, readyValues123...), + wantFirstReadingRaw: 7654321, + wantSecondReadingRaw: 123, + wantFirstReading: (7654321 - 5000) * 2, + wantSecondReading: (123 + 1000) * 4, + }, + /* + "error_update_read": { + simGainAndChannelCfg: A128, + simPinDtaGetValues: []bool{false}, + simClkPinSetErr: errors.New("set clk on update err"), + wantFirstReading: -5000 * 2, + wantSecondReading: +1000 * 4, + wantErr: "set clk on update err", + }, + */ + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // arrange + pClkMock := outputPinMock{ + //setErr: tc.simClkPinSetErr + } + pClk := newOutPin(&pClkMock) + pDtaMock := inputPinMock{getSimReturn: tc.simPinDtaGetValues} + pDta := newInPin(&pDtaMock) + d := New(pClk, pDta, tc.simGainAndChannelCfg) + d.devCfg = fastCfg + // small adjustments for calibration (raw values not affected) + d.firstReadingCfg.offset = -5000 + d.firstReadingCfg.calibrationFactor = 2 + d.secondReadingCfg.offset = 1000 + d.secondReadingCfg.calibrationFactor = 4 + // act + err := d.Update(0) + got1, got2 := d.Values() + // assert + if tc.wantErr != "" { + require.EqualError(t, err, tc.wantErr) + } else { + require.NoError(t, err) + } + assert.Equal(t, len(tc.simPinDtaGetValues), pDtaMock.getCalled) + assert.InDelta(t, tc.wantFirstReadingRaw, d.firstRawValue, 0.00001) + assert.InDelta(t, tc.wantSecondReadingRaw, d.secondRawValue, 0.00001) + assert.InDelta(t, tc.wantFirstReading, got1, 0.00001) + assert.InDelta(t, tc.wantSecondReading, got2, 0.00001) + }) + } +} + +func TestOffsetAndCalibrationFactor_set_get(t *testing.T) { + tests := map[string]struct { + secondReading bool + simOffs int32 + simCalFac float32 + wantOffs int32 + wantCalFac float32 + }{ + "set_first": { + simOffs: 345, + simCalFac: 567.89, + wantOffs: 345, + wantCalFac: 567.89, + }, + "set_second": { + secondReading: true, + simOffs: 45, + simCalFac: 67.89, + wantOffs: 45, + wantCalFac: 67.89, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // arrange + d := Device{mu: &sync.Mutex{}} + // act + d.SetOffsetAndCalibrationFactor(tc.simOffs, tc.simCalFac, tc.secondReading) + gotOffs, gotCalFac := d.OffsetAndCalibrationFactor(tc.secondReading) + // assert + assert.Equal(t, tc.wantOffs, gotOffs) + assert.InDelta(t, tc.wantCalFac, gotCalFac, 0) + if tc.secondReading { + assert.Equal(t, int32(0), d.firstReadingCfg.offset) + assert.InDelta(t, 0, d.firstReadingCfg.calibrationFactor, 0) + } else { + assert.Equal(t, int32(0), d.secondReadingCfg.offset) + assert.InDelta(t, 0, d.secondReadingCfg.calibrationFactor, 0) + } + }) + } +} + +//type outputPinMock drivers.PinOutput + +type outputPinMock struct { + setCalled []bool + setErr_ error +} + +func newOutPin(opm *outputPinMock) drivers.PinOutput { + return opm.set +} + +func (opm *outputPinMock) High() error { + return opm.Set(true) +} + +func (opm *outputPinMock) Low() error { + return opm.Set(false) +} + +func (opm *outputPinMock) set(val bool) { + if err := opm.Set(val); err != nil { + println(err.Error()) + } +} + +func (opm *outputPinMock) Set(val bool) error { + opm.setCalled = append(opm.setCalled, val) + return opm.setErr_ +} + +type inputPinMock struct { + getCalled int + getSimReturn []bool + getErr_ error +} + +//type inputPinMock drivers.PinInput + +func newInPin(ipm *inputPinMock) drivers.PinInput { + return ipm.get +} + +func (ipm *inputPinMock) get() bool { + val, _ := ipm.Get() + /* + if err != nil { + println(err.Error()) + } + */ + + return val +} + +func (ipm *inputPinMock) Get() (bool, error) { + ipm.getCalled++ + if len(ipm.getSimReturn) < ipm.getCalled { + return false, errors.New("error get, no value") + } + + return ipm.getSimReturn[ipm.getCalled-1], ipm.getErr_ +} diff --git a/pin.go b/pin.go new file mode 100644 index 000000000..023af310c --- /dev/null +++ b/pin.go @@ -0,0 +1,37 @@ +package drivers + +// PinOutput is hardware abstraction for a pin which outputs a +// digital signal (high or low level). +// +// // Code conversion demo: from machine.Pin to drivers.PinOutput +// led := machine.LED +// led.Configure(machine.PinConfig{Mode: machine.PinOutput}) +// var pin drivers.PinOutput = led.Set // Going from a machine.Pin to a drivers.PinOutput +type PinOutput func(level bool) + +// High sets the underlying pin's level to high. This is equivalent to calling PinOutput(true). +func (po PinOutput) High() { + po(true) +} + +// Low sets the underlying pin's level to low. This is equivalent to calling PinOutput(false). +func (po PinOutput) Low() { + po(false) +} + +func (po PinOutput) Set(level bool) { + po(level) +} + +// PinInput is hardware abstraction for a pin which receives a +// digital signal and reads it (high or low level). +// +// // Code conversion demo: from machine.Pin to drivers.PinInput +// input := machine.LED +// input.Configure(machine.PinConfig{Mode: machine.PinInputPulldown}) // or use machine.PinInputPullup or machine.PinInput +// var pin drivers.PinInput = input.Get // Going from a machine.Pin to a drivers.PinInput +type PinInput func() (level bool) + +func (pi PinInput) Get() bool { + return pi() +}