├── .github └── workflows │ ├── build-test-binary.yml │ └── gochecks.yml ├── .gitignore ├── .golangci.yaml ├── LICENSE ├── Makefile ├── README.md ├── brew.go ├── build ├── build.sh └── lint.sh ├── client.go ├── cmd.go ├── conf.go ├── daemon.go ├── go.mod ├── go.sum ├── hack ├── cc.chlc.batt.plist └── install.sh ├── handler.go ├── hook.c ├── hook.h ├── install.go ├── loop.go ├── loop_test.go ├── main.go ├── makefiles ├── common.mk ├── consts.mk └── targets.mk ├── nonbrew.go ├── pkg ├── smc │ ├── acpower.go │ ├── adapter.go │ ├── battery.go │ ├── charging.go │ ├── consts_amd64.go │ ├── consts_arm64.go │ ├── magsafe.go │ └── smc.go └── version │ └── version.go ├── sleepcallback.go └── util.go /.github/workflows/build-test-binary.yml: -------------------------------------------------------------------------------- 1 | name: Buind Test Binary 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - release-* 8 | tags: 9 | - "v*" 10 | pull_request: 11 | branches: 12 | - master 13 | - release-* 14 | workflow_dispatch: {} 15 | 16 | jobs: 17 | detect-noop: 18 | name: Detect No-op Changes 19 | runs-on: ubuntu-latest 20 | outputs: 21 | noop: ${{ steps.noop.outputs.should_skip }} 22 | steps: 23 | - name: Detect No-op Changes 24 | id: noop 25 | uses: fkirc/skip-duplicate-actions@v5 26 | with: 27 | github_token: ${{ secrets.GITHUB_TOKEN }} 28 | paths_ignore: '["**.md", "**.mdx", "**.png", "**.jpg", "**.svg"]' 29 | do_not_skip: '["workflow_dispatch", "schedule", "push"]' 30 | concurrent_skipping: false 31 | 32 | build: 33 | name: Build Binary 34 | runs-on: macos-14 35 | needs: detect-noop 36 | if: needs.detect-noop.outputs.noop != 'true' 37 | steps: 38 | - name: Checkout Code 39 | uses: actions/checkout@v3 40 | with: 41 | fetch-depth: 0 42 | 43 | - name: Install Go 44 | uses: actions/setup-go@v5 45 | with: 46 | go-version: 1.23 47 | 48 | - name: Build 49 | run: make build-darwin_arm64 50 | 51 | - name: Upload Artifacts 52 | uses: actions/upload-artifact@v4 53 | with: 54 | name: batt 55 | path: bin/batt 56 | -------------------------------------------------------------------------------- /.github/workflows/gochecks.yml: -------------------------------------------------------------------------------- 1 | name: Go Checks 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - release-* 8 | tags: 9 | - "v*" 10 | pull_request: 11 | branches: 12 | - master 13 | - release-* 14 | workflow_dispatch: {} 15 | 16 | env: 17 | GOLANGCI_VERSION: "v1.60.1" 18 | 19 | jobs: 20 | detect-noop: 21 | name: Detect No-op Changes 22 | runs-on: ubuntu-latest 23 | outputs: 24 | noop: ${{ steps.noop.outputs.should_skip }} 25 | steps: 26 | - name: Detect No-op Changes 27 | id: noop 28 | uses: fkirc/skip-duplicate-actions@v5 29 | with: 30 | github_token: ${{ secrets.GITHUB_TOKEN }} 31 | paths_ignore: '["**.md", "**.mdx", "**.png", "**.jpg", "**.svg"]' 32 | do_not_skip: '["workflow_dispatch", "schedule", "push"]' 33 | concurrent_skipping: false 34 | 35 | checks: 36 | name: Check Go Code 37 | runs-on: macos-14 38 | needs: detect-noop 39 | if: needs.detect-noop.outputs.noop != 'true' 40 | steps: 41 | - name: Checkout Code 42 | uses: actions/checkout@v3 43 | 44 | - name: Install Go 45 | uses: actions/setup-go@v5 46 | with: 47 | go-version: 1.23 48 | 49 | - name: Lint 50 | uses: golangci/golangci-lint-action@v6 51 | with: 52 | version: ${{ env.GOLANGCI_VERSION }} 53 | verify: false 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # macOS leaves these everywhere on SMB shares 2 | ._* 3 | # macOS misc 4 | .DS_Store 5 | 6 | # Eclipse files 7 | .classpath 8 | .project 9 | .settings/** 10 | 11 | # Files generated by JetBrains IDEs, e.g. IntelliJ IDEA 12 | .idea/ 13 | *.iml 14 | 15 | # VSCode files 16 | .vscode 17 | 18 | # Emacs save files 19 | *~ 20 | \#*\# 21 | .\#* 22 | 23 | # Vim-related files 24 | [._]*.s[a-w][a-z] 25 | [._]s[a-w][a-z] 26 | *.un~ 27 | Session.vim 28 | .netrwhist 29 | 30 | # cscope-related files 31 | cscope.* 32 | 33 | # JUnit test output from ginkgo e2e tests 34 | /junit*.xml 35 | 36 | # Mercurial files 37 | **/.hg 38 | **/.hg* 39 | 40 | # Vagrant 41 | .vagrant 42 | 43 | # Binaries for programs and plugins 44 | *.exe 45 | *.exe~ 46 | *.dll 47 | *.so 48 | *.dylib 49 | 50 | # Test binary, built with `go test -c` 51 | *.test 52 | 53 | # Output of the go coverage tool, specifically when used with LiteIDE 54 | *.out 55 | 56 | # Build output 57 | bin 58 | .go 59 | 60 | # etcd 61 | default.etcd 62 | 63 | *.tmp 64 | 65 | vendor 66 | main 67 | batt 68 | 69 | # Generated docker ignore 70 | # This is automatically generated by Make depending on which image you want to build. 71 | .dockerignore 72 | 73 | # C 74 | *.o -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 10m 3 | 4 | skip-files: 5 | - "zz_generated\\..+\\.go$" 6 | - ".*_test.go$" 7 | 8 | skip-dirs: 9 | - "hack" 10 | - "e2e" 11 | - "bin" 12 | 13 | output: 14 | # colored-line-number|line-number|json|tab|checkstyle|code-climate, default is "colored-line-number" 15 | format: colored-line-number 16 | 17 | linters-settings: 18 | errcheck: 19 | # report about not checking of errors in type assetions: `a := b.(MyStruct)`; 20 | # default is false: such cases aren't reported by default. 21 | check-type-assertions: false 22 | 23 | # report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`; 24 | # default is false: such cases aren't reported by default. 25 | check-blank: false 26 | 27 | # [deprecated] comma-separated list of pairs of the form pkg:regex 28 | # the regex is used to ignore names within pkg. (default "fmt:.*"). 29 | # see https://github.com/kisielk/errcheck#the-deprecated-method for details 30 | ignore: fmt:.*,io/ioutil:^Read.* 31 | 32 | exhaustive: 33 | # indicates that switch statements are to be considered exhaustive if a 34 | # 'default' case is present, even if all enum members aren't listed in the 35 | # switch 36 | default-signifies-exhaustive: true 37 | 38 | govet: 39 | # report about shadowed variables 40 | check-shadowing: false 41 | 42 | gofmt: 43 | # simplify code: gofmt with `-s` option, true by default 44 | simplify: true 45 | 46 | goimports: 47 | # put imports beginning with prefix after 3rd-party packages; 48 | # it's a comma-separated list of prefixes 49 | local-prefixes: github.com/oam-dev/kubevela 50 | 51 | gocyclo: 52 | # minimal code complexity to report, 30 by default (but we recommend 10-20) 53 | min-complexity: 30 54 | 55 | maligned: 56 | # print struct with more effective memory layout or not, false by default 57 | suggest-new: true 58 | 59 | dupl: 60 | # tokens count to trigger issue, 150 by default 61 | threshold: 100 62 | 63 | goconst: 64 | # minimal length of string constant, 3 by default 65 | min-len: 3 66 | # minimal occurrences count to trigger, 3 by default 67 | min-occurrences: 5 68 | 69 | lll: 70 | # tab width in spaces. Default to 1. 71 | tab-width: 1 72 | 73 | unused: 74 | # treat code as a program (not a library) and report unused exported identifiers; default is false. 75 | # XXX: if you enable this setting, unused will report a lot of false-positives in text editors: 76 | # if it's called for subdir of a project it can't find funcs usages. All text editor integrations 77 | # with golangci-lint call it on a directory with the changed file. 78 | check-exported: false 79 | 80 | unparam: 81 | # Inspect exported functions, default is false. Set to true if no external program/library imports your code. 82 | # XXX: if you enable this setting, unparam will report a lot of false-positives in text editors: 83 | # if it's called for subdir of a project it can't find external interfaces. All text editor integrations 84 | # with golangci-lint call it on a directory with the changed file. 85 | check-exported: false 86 | 87 | nakedret: 88 | # make an issue if func has more lines of code than this setting and it has naked returns; default is 30 89 | max-func-lines: 30 90 | 91 | gocritic: 92 | # Enable multiple checks by tags, run `GL_DEBUG=gocritic golangci-lint` run to see all tags and checks. 93 | # Empty list by default. See https://github.com/go-critic/go-critic#usage -> section "Tags". 94 | enabled-tags: 95 | - performance 96 | 97 | settings: # settings passed to gocritic 98 | captLocal: # must be valid enabled check name 99 | paramsOnly: true 100 | rangeValCopy: 101 | sizeThreshold: 32 102 | 103 | makezero: 104 | # Allow only slices initialized with a length of zero. Default is false. 105 | always: false 106 | 107 | linters: 108 | enable: 109 | - megacheck 110 | - govet 111 | - gocyclo 112 | - gocritic 113 | - goconst 114 | - goimports 115 | - gofmt # We enable this as well as goimports for its simplify mode. 116 | - revive 117 | - unconvert 118 | - misspell 119 | - nakedret 120 | fast: false 121 | 122 | 123 | issues: 124 | # Excluding configuration per-path and per-linter 125 | exclude-rules: 126 | # Exclude some linters from running on tests files. 127 | - path: _test(ing)?\.go 128 | linters: 129 | - gocyclo 130 | - errcheck 131 | - dupl 132 | - gosec 133 | - exportloopref 134 | - unparam 135 | 136 | # Ease some gocritic warnings on test files. 137 | - path: _test\.go 138 | text: "(unnamedResult|exitAfterDefer)" 139 | linters: 140 | - gocritic 141 | 142 | # These are performance optimisations rather than style issues per se. 143 | # They warn when function arguments or range values copy a lot of memory 144 | # rather than using a pointer. 145 | - text: "(hugeParam|rangeValCopy):" 146 | linters: 147 | - gocritic 148 | 149 | # This "TestMain should call os.Exit to set exit code" warning is not clever 150 | # enough to notice that we call a helper method that calls os.Exit. 151 | - text: "SA3000:" 152 | linters: 153 | - staticcheck 154 | 155 | - text: "package-comments: should have a package comment" 156 | linters: 157 | - revive 158 | 159 | - text: "k8s.io/api/core/v1" 160 | linters: 161 | - goimports 162 | 163 | # This is a "potential hardcoded credentials" warning. It's triggered by 164 | # any variable with 'secret' in the same, and thus hits a lot of false 165 | # positives in Kubernetes land where a Secret is an object type. 166 | - text: "G101:" 167 | linters: 168 | - gosec 169 | - gas 170 | 171 | # This is an 'errors unhandled' warning that duplicates errcheck. 172 | - text: "G104:" 173 | linters: 174 | - gosec 175 | - gas 176 | 177 | # The Azure AddToUserAgent method appends to the existing user agent string. 178 | # It returns an error if you pass it an empty string lettinga you know the 179 | # user agent did not change, making it more of a warning. 180 | - text: \.AddToUserAgent 181 | linters: 182 | - errcheck 183 | 184 | - text: "don't use an underscore" 185 | linters: 186 | - revive 187 | 188 | # Independently from option `exclude` we use default exclude patterns, 189 | # it can be disabled by this option. To list all 190 | # excluded by default patterns execute `golangci-lint run --help`. 191 | # Default value for this option is true. 192 | exclude-use-default: false 193 | 194 | # Show only new issues: if there are unstaged changes or untracked files, 195 | # only those changes are analyzed, else only changes in HEAD~ are analyzed. 196 | # It's a super-useful option for integration of golangci-lint into existing 197 | # large codebase. It's not practical to fix all existing issues at the moment 198 | # of integration: much better don't allow issues in new code. 199 | # Default is false. 200 | new: false 201 | 202 | # Maximum issues count per one linter. Set to 0 to disable. Default is 50. 203 | max-per-linter: 0 204 | 205 | # Maximum count of issues with the same text. Set to 0 to disable. Default is 3. 206 | max-same-issues: 0 207 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Charlie Chiang 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # Setup make 16 | include makefiles/common.mk 17 | 18 | # Settings for this subproject 19 | # Entry file, containing func main 20 | ENTRY := . 21 | # All supported platforms for binary distribution 22 | BIN_PLATFORMS := darwin/arm64 23 | # Binary basename (.exe will be automatically added when building for Windows) 24 | BIN := batt 25 | 26 | # Setup make variables 27 | include makefiles/consts.mk 28 | 29 | # Setup common targets 30 | include makefiles/targets.mk 31 | 32 | lint: 33 | bash build/lint.sh 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Note: use table of contents of quickly navigate to the section you want. 👆↗ 2 | 3 | # batt 4 | 5 | [![Go Checks](https://github.com/charlie0129/batt/actions/workflows/gochecks.yml/badge.svg)](https://github.com/charlie0129/batt/actions/workflows/gochecks.yml)[![Buind Test Binary](https://github.com/charlie0129/batt/actions/workflows/build-test-binary.yml/badge.svg)](https://github.com/charlie0129/batt/actions/workflows/build-test-binary.yml) 6 | 7 | `batt` is a tool to control battery charging on Apple Silicon MacBooks. 8 | 9 | ## Why do you need this? 10 | 11 | [This article](https://batteryuniversity.com/article/bu-808-how-to-prolong-lithium-based-batteries) might be helpful. TL;DR: keep your battery at 80% or lower when plugged in, and discharge it as shallowly as feasible. 12 | 13 | Previously, before optimized battery charging is introduced, MacBooks are known to suffer from battery swelling when they are kept at 100% all the time, especially the 2015s. Even with optimized battery charging, the effect is not optimal (described [below](#but-macos-have-similar-features-built-in-is-it)). 14 | 15 | `batt` can effectively alleviate this problem by limiting the battery charge level. It can be used to set a maximum charge level. For example, you can set it to 80%, and it will stop charging when the battery reaches 80%. Once it reaches the predefined level, your computer will use power from the wall _only_, leaving no strain on your battery. 16 | 17 | Quick link to [installation guide](#installation). 18 | 19 | ## Features 20 | 21 | `batt` tried to keep as simple as possible. Charging limiting is the only thing to care about for most users: 22 | 23 | - Limit battery charge, with a lower and upper bound, like ThinkPads. [Docs](#limit-battery-charge) 24 | 25 | However, if you are nerdy and want to dive into the details, it does have some advanced features for the computer nerds out there :) 26 | 27 | - Control MagSafe LED (if present) according to charge status. [Docs](#control-magsafe-led) 28 | - Cut power from the wall (even if the adapter is physically plugged in) to use battery power. [Docs](#enabledisable-power-adapter) 29 | - It solves common sleep-related issues when controlling charging. [Docs1](#preventing-idle-sleep) [Docs2](#disabling-charging-before-sleep) 30 | 31 | ## How is it different from XXX? 32 | 33 | **It is free and opensource**. It even comes with some features (like idle sleep preventions and pre-sleep stop charging) that are only available in paid counterparts. It comes with no ads, no tracking, no telemetry, no analytics, no bullshit. It is open source, so you can read the code and verify that it does what it says it does. 34 | 35 | **It is simple but well-thought.** It only does charge limiting and does it well. For example, when using other free/unpaid tools, your MacBook will sometimes charge to 100% during sleep even if you set the limit to, like, 60%. `batt` have taken these edge cases into consideration and will behave as intended (in case you do encounter problems, please raise an issue so that we can solve it). Other features is intentionally limited to keep it simple. If you want some additional features, feel free to raise an issue, then we can discuss. 36 | 37 | **It is light-weight.** As a command-line tool, it is light-weight by design. No electron GUIs hogging your system resources. However, a native GUI that sits in the menubar is a good addition. 38 | 39 | ## But macOS have similar features built-in, is it? 40 | 41 | Yes, macOS have optimized battery charging. It will try to find out your charging and working schedule and prohibits charging above 80% for a couple of hours overnight. However, if you have an un-regular schedule, this will simply not work. Also, you lose predictability (which I value a lot) about your computer's behavior, i.e., by letting macOS decide for you, you, the one who knows your schedule the best, cannot control when to charge or when not to charge. 42 | 43 | `batt` can make sure your computer does exactly what you want. You can set a maximum charge level, and it will stop charging when the battery reaches that level. Therefore, it is recommended to disable macOS's optimized charging when using `batt`. 44 | 45 | ## Installation 46 | 47 | > FYI: You are reading the instructions for `batt`, which is a commandline-only (CLI-only) application. There are some great 3rd-party GUI frontends built around `batt` by amazing opensource developers. If you find the commandline intimidating, you can use these GUI versions instead. Check out them: 48 | > 1. [BattGUI](https://github.com/clzoc/BattGUI) by [@clzoc](https://github.com/clzoc) 49 | 50 | You have two choices to install `batt`: 51 | 52 | 1. Homebrew (If you prefer a package manager) [Docs](#homebrew) 53 | 2. Installation Script (Recommended) [Docs](#installation-script) 54 | 55 | You can choose either one. Please do not use both at the same time to avoid conflicts. 56 | 57 | ### Homebrew 58 | 59 | 1. `brew install batt` 60 | 2. `sudo brew services start batt` 61 | 3. Please read [Notes](#notes). 62 | 63 | > Thank you, [@Jerry1144](https://github.com/charlie0129/batt/issues/9#issuecomment-2165493285), for bootstrapping the Homebrew formula. 64 | 65 | ### Installation Script 66 | 67 | 1. (Optional) There is an installation script to help you quickly install batt (Internet connection required). Put this in your terminal: `bash <(curl -fsSL https://github.com/charlie0129/batt/raw/master/hack/install.sh)`. You may need to provide your login password (to control charging). This will download and install the latest _stable_ version for you. Follow the on-screen instructions, then you can skip to step 5. 68 |
69 | Manual installation steps 70 | 71 | 2. Get the binary. For _stable_ and _beta_ releases, you can find the download link in the [release page](https://github.com/charlie0129/batt/releases). If you want development versions with the latest features and bug fixes, you can download prebuilt binaries from [GitHub Actions](https://github.com/charlie0129/batt/actions/workflows/build-test-binary.yml) (has a retention period of 3 months and you need to `chmod +x batt` after extracting the archive) or [build it yourself](#building) . 72 | 3. Put the binary somewhere safe. You don't want to move it after installation :). It is recommended to save it in your `$PATH`, e.g., `/usr/local/bin`, so you can directly call `batt` on the command-line. In this case, the binary location will be `/usr/local/bin/batt`. 73 | 4. Install daemon using `sudo batt install`. If you do not want to use `sudo` every time after installation, add the `--allow-non-root-access` flag: `sudo batt install --allow-non-root-access`. To uninstall: please refer to [How to uninstall?](#how-to-uninstall) 74 |
75 | 76 | 5. In case you have GateKeeper turned on, you will see something like _"batt is can't be opened because it was not downloaded from the App Store"_ or _"batt cannot be opened because the developer cannot be verified"_. If you don't see it, you can skip this step. To solve this, you can either 1. (recommended) Go to *System Settings* -> *Privacy & Security* --scroll-down--> *Security* -> *Open Anyway*; or 2. run `sudo spctl --master-disable` to disable GateKeeper entirely. 77 | 78 | ### Notes 79 | 80 | - Test if it works by running `sudo batt status`. If you see your battery status, you are good to go! 81 | - Time to customize. By default `batt` will set a charge limit to 60%. For example, to set the charge limit to 80%, run `sudo batt limit 80`. 82 | - As said before, it is _highly_ recommended to disable macOS's optimized charging when using `batt`. To do so, open _System Settings_ -> _Battery_ -> _Battery Health_ -> _i_ -> Trun OFF _Optimized Battery Charging_ 83 | - If your current charge is above the limit, your computer will just stop charging and use power from the wall. It will stay at your current charge level, which is by design. You can use your battery until it is below the limit to see the effects. 84 | - You can refer to [Usage](#usage) for additional configurations. Don't know what a command does? Run `batt help` to see all available commands. To see help for a specific command, run `batt help `. 85 | - To disable the charge limit, run `batt disable` or `batt limit 100`. 86 | - [How to uninstall?](#how-to-uninstall) [How to upgrade?](#how-to-upgrade) 87 | 88 | > Finally, if you find `batt` helpful, stars ⭐️ are much appreciated! 89 | 90 | ## Usage 91 | 92 | ### Limit battery charge 93 | 94 | Make sure your computer doesn't charge beyond what you said. 95 | 96 | Setting the limit to 10-99 will enable the battery charge limit, limiting the maximum charge to _somewhere around_ your setting. However, setting the limit to 100 will disable the battery charge limit. 97 | 98 | By default, `batt` will set a 60% charge limit. 99 | 100 | To customize charge limit, see `batt limit`. For example,to set the limit to 80%, run `batt limit 80`. To disable the limit, run `batt disable` or `batt limit 100`. 101 | 102 | ### Enable/disable power adapter 103 | 104 | Cut or restore power from the wall. This has the same effect as unplugging/plugging the power adapter, even if the adapter is physically plugged in. 105 | 106 | This is useful when you want to use your battery to lower the battery charge, but you don't want to unplug the power adapter. 107 | 108 | NOTE: if you are using Clamshell mode (using a Mac laptop with an external monitor and the lid closed), *cutting power will cause your Mac to go to sleep*. This is a limitation of macOS. There are ways to prevent this, but it is not recommended for most users. 109 | 110 | To enable/disable power adapter, see `batt adapter`. For example, to disable the power adapter, run `sudo batt adapter disable`. To enable the power adapter, run `sudo batt adapter enable`. 111 | 112 | ### Check status 113 | 114 | Check the current config, battery info, and charging status. 115 | 116 | To do so, run `sudo batt status`. 117 | 118 | ## Advanced 119 | 120 | These advanced features are not for most users. Using the default setting for these options should work the best. 121 | 122 | ### Preventing idle sleep 123 | 124 | Set whether to prevent idle sleep during a charging session. 125 | 126 | Due to macOS limitations, `batt` will be paused when your computer goes to sleep. As a result, when you are in a charging session and your computer goes to sleep, there is no way for batt to stop charging (since batt is paused by macOS) and the battery will charge to 100%. This option, together with disable-charging-pre-sleep, will prevent this from happening. 127 | 128 | This option tells macOS NOT to go to sleep when the computer is in a charging session, so batt can continue to work until charging is finished. Note that it will only prevent **idle** sleep, when 1) charging is active 2) battery charge limit is enabled. So your computer can go to sleep as soon as a charging session is completed. 129 | 130 | However, this options does not prevent manual sleep (limitation of macOS). For example, if you manually put your computer to sleep (by choosing the Sleep option in the top-left Apple menu) or close the lid, batt will still be paused and the issue mentioned above will still happen. This is where disable-charging-pre-sleep comes in. See [Disabling charging before sleep](#disabling-charging-before-sleep). 131 | 132 | To enable this feature, run `sudo batt prevent-idle-sleep enable`. To disable, run `sudo batt prevent-idle-sleep disable`. 133 | 134 | ### Disabling charging before sleep 135 | 136 | Set whether to disable charging before sleep if charge limit is enabled. 137 | 138 | As described in [Preventing idle sleep](#preventing-idle-sleep), batt will be paused by macOS when your computer goes to sleep, and there is no way for batt to continue controlling battery charging. This option will disable charging just before sleep, so your computer will not overcharge during sleep, even if the battery charge is below the limit. 139 | 140 | To enable this feature, run `sudo batt disable-charging-pre-sleep enable`. To disable, run `sudo batt disable-charging-pre-sleep disable`. 141 | 142 | ### Upper and lower charge limit 143 | 144 | When you set a charge limit, for example, on a Lenovo ThinkPad, you can set two percentages. The first one is the upper limit, and the second one is the lower limit. When the battery charge is above the upper limit, the computer will stop charging. When the battery charge is below the lower limit, the computer will start charging. If the battery charge is between the two limits, the computer will keep whatever charging state it is in. 145 | 146 | `batt` have similar features built-in. The charge limit you have set (using `batt limit`) will be used as the upper limit. By default, The lower limit will be set to 2% less than the upper limit. To customize the lower limit, use `batt lower-limit-delta`. 147 | 148 | For example, if you want to set the lower limit to be 5% less than the upper limit, run `sudo batt lower-limit-delta 5`. So, if you have your charge (upper) limit set to 60%, the lower limit will be 55%. 149 | 150 | ### Control MagSafe LED 151 | 152 | > Acknowledgement: [@exidler](https://github.com/exidler) 153 | 154 | This option can make the MagSafe LED on your MacBook change color according to the charging status. For example: 155 | 156 | - Green: charge limit is reached and charging is stopped. 157 | - Orange: charging is in progress. 158 | - Off: just woken up from sleep, charing is disabled and batt is waiting before controlling charging. 159 | 160 | Note that you must have a MagSafe LED on your MacBook to use this feature. 161 | 162 | To enable MagSafe LED control, run `sudo batt magsafe-led enable`. 163 | 164 | ### Check logs 165 | 166 | Logs are directed to `/tmp/batt.log`. If something goes wrong, you can check the logs to see what happened. Raise an issue with the logs attached, so we can debug together. 167 | 168 | ## Building 169 | 170 | You need to install command line developer tools (by running `xcode-select --install`) and Go (follow the official instructions [here](https://go.dev/doc/install)). 171 | 172 | Simply running `make` in this repo should build the binary into `./bin/batt`. You can then follow [the upgrade guide](#how-to-upgrade) to install it (you just use the binary you have built, not downloading a new one, of course). 173 | 174 | ## Architecture 175 | 176 | You can think of `batt` like `docker`. It has a daemon that runs in the background, and a client that communicates with the daemon. They communicate through unix domain socket as a way of IPC. The daemon does the actual heavy-lifting, and is responsible for controlling battery charging. The client is responsible for sending users' requirements to the daemon. 177 | 178 | For example, when you run `sudo batt limit 80`, the client will send the requirement to the daemon, and the daemon will do its job to keep the charge limit to 80%. 179 | 180 | ## Compatibility 181 | 182 | batt should be compatible with _any_ Apple Silicon MacBook (not desktop Macs), running macOS Monterey and later. 183 | 184 | If you want to know which MacBooks I personally developed it on, I am using it on all my personal MacBooks every single day, including MacBook Air M1 2020 (A2337), MacBook Air M2 2022 (A2681), MacBook Pro 14' M1 Pro 2021 (A2442), MacBook Pro 16' M1 Max 2021 (A2485). 185 | 186 | If you encounter any incompatibility, please raise an issue with your MacBook model and macOS version. 187 | 188 | ## Motivation 189 | 190 | I created this tool simply because I am not satisfied with existing tools 😐. 191 | 192 | I have written and using similar utils (to limit charging) on Intel MacBooks for years. Finally I got hands on an Apple Silicon MacBook (yes, 2 years later since it is introduced 😅 and I just got my first one). The old BCLM way to limit charging doesn't work anymore. I was looking for a tool to limit charging on M1 MacBooks. 193 | 194 | I have tried some alternatives, both closed source and open source, but I kept none of them. Some paid alternatives' licensing options are just too limited 🤔, a bit bloated, require periodic Internet connection (hmm?) and are closed source. It doesn't seem a good option for me. Some open source alternatives just don't handle edge cases well and I encountered issues regularly especially when sleeping (as of Apr 2023). 195 | 196 | I want a _simple_ tool that does just one thing, and **does it well** -- limiting charging, just like the [Unix philosophy](https://en.wikipedia.org/wiki/Unix_philosophy). It seems I don't have any options but to develop by myself. So I spent a weekend developing an MVP, so here we are! `batt` is here! 197 | 198 | ## FAQ 199 | 200 | ### How to uninstall? 201 | 202 | Note that you should choose the same method as you used to install `batt` to uninstall it. 203 | 204 | If you don't remember how you installed it, you can check the binary location by running `which batt`. If it is in `/usr/local/bin`, you probably used the installation script. If it is in `/opt/homebrew/bin`, you probably used Homebrew. 205 | 206 | Script-installed: 207 | 208 | 1. Run `sudo batt uninstall` to remove the daemon. 209 | 2. Remove the config by `sudo rm /etc/batt.json`. 210 | 3. Remove the `batt` binary itself by `sudo rm $(which batt)`. 211 | 212 | Homebrew-installed: 213 | 214 | 1. Run `sudo batt disable` to restore the default charge limit. 215 | 2. Run `sudo brew services stop batt` to stop the daemon. 216 | 3. Run `brew uninstall batt` to uninstall the binary. 217 | 4. Remove the config by `sudo rm /opt/homebrew/etc/batt.json`. 218 | 219 | ### How to upgrade? 220 | 221 | Note that you should choose the same method as you used to install `batt` to upgrade it. 222 | 223 | If you don't remember how you installed it, you can check the binary location by running `which batt`. If it is in `/usr/local/bin`, you probably used the installation script. If it is in `/opt/homebrew/bin`, you probably used Homebrew. 224 | 225 | Script-installed: 226 | 227 | ```bash 228 | bash <(curl -fsSL https://github.com/charlie0129/batt/raw/master/hack/install.sh) 229 | ``` 230 | 231 | Homebrew-installed: 232 | 233 | ```bash 234 | sudo brew services stop batt 235 | brew upgrade batt 236 | sudo brew services start batt 237 | ``` 238 | 239 | Manual: 240 | 241 | 1. Run `sudo batt uninstall` to remove the old daemon. 242 | 2. Download the new binary. For _stable_ and _beta_ releases, you can find the download link in the [release page](https://github.com/charlie0129/batt/releases). If you want development versions with the latest features and bug fixes, you can download prebuilt binaries from [GitHub Actions](https://github.com/charlie0129/batt/actions/workflows/build-test-binary.yml) (has a retention period of 3 months and you need to `chmod +x batt` after extracting the archive) or [build it yourself](#building) . 243 | 3. Replace the old `batt` binary with the downloaded new one. `sudo cp ./batt $(where batt)` 244 | 4. Run `sudo batt install` to install the daemon again. Although most config is preserved, some security related config is intentionally reset during re-installation. For example, if you used `--allow-non-root-access` when installing previously, you will need to use it again like this `sudo batt install --allow-non-root-access`. 245 | 246 | ### Why is it Apple Silicon only? 247 | 248 | You probably don't need this on Intel :p 249 | 250 | On Intel MacBooks, you can control battery charging in a much, much easier way, simply setting the `BCLM` key in Apple SMC to the limit you need, and you are all done. There are many tools available. For example, you can use [smc-command](https://github.com/hholtmann/smcFanControl/tree/master/smc-command) to set SMC keys. Of course, you will lose some advanced features like upper and lower limit. 251 | 252 | However, on Apple Silicon, the way how charging is controlled changed. There is no such key. Therefore, we have to use a much more complicated way to achieve the same goal, and handle more edge cases, hence `batt`. 253 | 254 | ### Will there be an Intel version? 255 | 256 | Probably not. `batt` was made Apple-Silicon-only after some early development. I have tested batt on Intel during development (you can probably find some traces from the code :). Even though some features in batt are known to work on Intel, some are not. Development and testing on Intel requires additional effort, especially those feature that are not working. Considering the fact that Intel MacBooks are going to be obsolete in a few years and some similar tools already exist (without some advanced features), I don't think it is worth the effort. 257 | 258 | ### Why does my MacBook stop charging after I close the lid? 259 | 260 | TL,DR; This is intended, and is the default behavior. It is described [here](#disabling-charging-before-sleep). You can turn this feature off by running `sudo batt disable-charging-pre-sleep disable` (not recommended, keep reading). 261 | 262 | But it is suggested to keep the default behavior to make your charge limit work as intended. Why? Because when you close the lid, your MacBook will go into **forced sleep**, and `batt` will be paused by macOS. As a result, `batt` can no longer control battery charging. It will be whatever state it was before you close the lid. This is the problem. Let's say, if you close the lid when your MacBook is charging, since `batt` is paused by macOS, it will keep charging, ignoring the charge limit you have set. There is no way to prevent **forced sleep**. Therefore, the only way to solve this problem is to disable charging before sleep. This is what `batt` does. It will disable charging just before your MacBook goes to sleep, and re-enable it when it wakes up. This way, your Mac will not overcharge during sleep. 263 | 264 | Not that you will encounter this **forced sleep** only if you, the user, forced the Mac to sleep, either by closing the lid or selecting the Sleep option in the Apple menu. If your Mac decide to sleep by itself, called **idle sleep**, i.e. when it is idle for a while, in this case, you will not experience this stop-charging-before-sleep situation. 265 | 266 | So it is suggested to keep this feature on. But _What if I MUST let my Mac charge during a **forced sleep** without turing off `disable-charging-pre-sleep`, even if it may charge beyond the charge limit?_ This is simple, just disable charge limit `batt disable`. This way, when you DO want to enable charge limit again, `disable-charging-pre-sleep` will still be there to prevent overcharging. The rationale is: when you want to charge during a **forced sleep**, you actually want heavy use of your battery and don't want ANY charge limit at all, e.g. when you are on a long outside-event, and you want to charge your Mac when it is sitting in your bag, lid closed. Setting the charge limit to 100% is equivalent to disabling charge limit. Therefore, most `batt` features will be turned off and your Mac can charge as if `batt` is not installed. 267 | 268 | ### Why does it require root privilege? 269 | 270 | It writes to SMC to control battery charging. This does changes to your hardware, and is a highly privileged operation. Therefore, it requires root privilege. 271 | 272 | If you are concerned about security, you can check the source code [here](https://github.com/charlie0129/batt) to make sure it does not do anything funny. 273 | 274 | ### Why is it written in Go and C? 275 | 276 | Since it is a hobby project, I want to balance effort and the final outcome. Go seems a good choice for me. However, C is required to register sleep and wake notifications using Apple's IOKit framework. Also, Go don't have any library to r/w SMC, so I have to write it myself ([charlie0129/gosmc](https://github.com/charlie0129/gosmc)). This part is also mainly written in C as it interacts with the hardware and uses OS capabilities. Thankfully, writing a library didn't slow down development too much. 277 | 278 | ### Why is there so many logs? 279 | 280 | By default, `batt` daemon will have its log level set to `debug` for easy debugging. The `debug` logs are helpful when reporting problems since it contains useful information. So it is recommended to keep it as `debug`. You may find a lot of logs in `/tmp/batt.log` after you use your Mac for a few days. However, there is no need to worry about this. The logs will be cleaned by macOS on reboot. It will not grow indefinitely. 281 | 282 | If you believe you will not encounter any problem in the future and still want to set a higher log level, you can achieve this by: 283 | 284 | 1. Stop batt: `sudo launchctl unload /Library/LaunchDaemons/cc.chlc.batt.plist` (batt must be stopped to change config so you can't skip this step) 285 | 2. Use your preferred editor to edit `/Library/LaunchDaemons/cc.chlc.batt.plist` and change the value of `-l=debug` to your preferred level. The default value is `debug`. 286 | 3. Start batt again: `sudo launchctl load /Library/LaunchDaemons/cc.chlc.batt.plist` 287 | 288 | ### Why does my Mac go to sleep when I disable the power adapter? 289 | 290 | You are probably using Clamshell mode, i.e., using a Mac laptop with an external monitor and the lid closed. This is a limitation of macOS. Clamshell mode MUST have power connected, otherwise, the Mac will go to sleep. 291 | 292 | If you want to prevent this, you can use a third-party app like [Amphetamine](https://apps.apple.com/us/app/amphetamine/id937984704?mt=12) to prevent sleep. 293 | 294 | ### My Mac does not start charging after waking up from sleep 295 | 296 | This is expected. batt will prevent your Mac from charging temporarily if your Mac has just woken up from sleep. This is to prevent overcharging during sleep. Your Mac will start charging soon (at most 2 minutes). 297 | 298 | If you absolutely need to charge your Mac _immediately_ after waking up from sleep, you can disable this feature by running `sudo batt disable-charging-pre-sleep disable`. However, this is not recommended (see [Disabling charging before sleep](#disabling-charging-before-sleep)). 299 | 300 | ## Acknowledgements 301 | 302 | - [actuallymentor/battery](https://github.com/actuallymentor/battery) for various SMC keys. 303 | - [hholtmann/smcFanControl](https://github.com/hholtmann/smcFanControl) for its C code to read/write SMC, which inspires [charlie0129/gosmc](https://github.com/charlie0129/gosmc). 304 | - [Apple](https://developer.apple.com/library/archive/qa/qa1340/_index.html) for its guide to register and unregister sleep and wake notifications. 305 | - [@exidler](https://github.com/exidler) for building the MagSafe LED controlling logic. 306 | 307 | ## Star History 308 | 309 | [![Star History Chart](https://api.star-history.com/svg?repos=charlie0129/batt&type=Date)](https://www.star-history.com/#charlie0129/batt&Date) 310 | -------------------------------------------------------------------------------- /brew.go: -------------------------------------------------------------------------------- 1 | //go:build brew 2 | 3 | package main 4 | 5 | import ( 6 | "errors" 7 | 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | // NewInstallCommand . 12 | func NewInstallCommand() *cobra.Command { 13 | cmd := &cobra.Command{ 14 | Use: "install", 15 | Hidden: true, 16 | RunE: func(cmd *cobra.Command, _ []string) error { 17 | return errors.New("install command is not available on Homebrew-installed batt. Use `sudo brew services start batt` instead.") 18 | }, 19 | } 20 | 21 | cmd.Flags().Bool("allow-non-root-access", false, "Allow non-root users to access batt daemon.") 22 | 23 | return cmd 24 | } 25 | 26 | // NewUninstallCommand . 27 | func NewUninstallCommand() *cobra.Command { 28 | return &cobra.Command{ 29 | Use: "uninstall", 30 | Hidden: true, 31 | RunE: func(cmd *cobra.Command, _ []string) error { 32 | return errors.New("uninstall command is not available on Homebrew-installed batt. Use `sudo brew services stop batt` instead.") 33 | }, 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /build/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Copyright 2022 Charlie Chiang 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | set -o errexit 18 | set -o nounset 19 | 20 | if [ -z "${OS:-}" ]; then 21 | echo "OS must be set" 22 | exit 1 23 | fi 24 | 25 | if [ -z "${ARCH:-}" ]; then 26 | echo "ARCH must be set" 27 | exit 1 28 | fi 29 | 30 | if [ -z "${VERSION:-}" ]; then 31 | echo "VERSION is not set, defaulting to 'UNKNOWN'" 32 | fi 33 | 34 | if [ -z "${GIT_COMMIT:-}" ]; then 35 | echo "GIT_COMMIT is not set, defaulting to 'UNKNOWN'" 36 | fi 37 | 38 | if [ -z "${OUTPUT:-}" ]; then 39 | echo "OUTPUT must be set" 40 | exit 1 41 | fi 42 | 43 | # this project must use cgo 44 | export CGO_ENABLED=1 45 | export GOARCH="${ARCH}" 46 | export GOOS="${OS}" 47 | export GO111MODULE=on 48 | export GOFLAGS="${GOFLAGS:-} -mod=mod " 49 | 50 | printf "# BUILD output: %s\ttarget: %s/%s\tversion: %s\ttags: %s\n" \ 51 | "${OUTPUT}" "${OS}" "${ARCH}" "${VERSION}" "${GOTAGS:- }" 52 | 53 | printf "# BUILD building for " 54 | 55 | if [ "${DEBUG:-}" != "1" ]; then 56 | # release build 57 | # trim paths, disable symbols and DWARF. 58 | goasmflags="all=-trimpath=$(pwd)" 59 | gogcflags="all=-trimpath=$(pwd)" 60 | goldflags="-s -w" 61 | 62 | printf "release...\n" 63 | else 64 | # debug build 65 | # disable optimizations and inlining 66 | gogcflags="all=-N -l" 67 | goasmflags="" 68 | goldflags="" 69 | 70 | printf "debug...\n" 71 | fi 72 | 73 | # Set some version info. 74 | always_ldflags="" 75 | if [ -n "${VERSION:-}" ]; then 76 | always_ldflags="${always_ldflags} -X $(go list -m)/pkg/version.Version=${VERSION}" 77 | fi 78 | if [ -n "${GIT_COMMIT:-}" ]; then 79 | always_ldflags="${always_ldflags} -X $(go list -m)/pkg/version.GitCommit=${GIT_COMMIT}" 80 | fi 81 | 82 | export CGO_CFLAGS="-O2" 83 | export CGO_LDFLAGS="-O2" 84 | 85 | go build \ 86 | -gcflags="${gogcflags}" \ 87 | -tags="${GOTAGS:-}" \ 88 | -asmflags="${goasmflags}" \ 89 | -ldflags="${always_ldflags} ${goldflags}" \ 90 | -o "${OUTPUT}" \ 91 | "$@" 92 | -------------------------------------------------------------------------------- /build/lint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Copyright 2022 Charlie Chiang 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | set -o errexit 18 | set -o nounset 19 | set -o pipefail 20 | 21 | GOLANGCI_VERSION="1.60.1" 22 | 23 | GOLANGCI="${GOLANGCI:-golangci-lint}" 24 | 25 | if [ -f "bin/golangci-lint" ]; then 26 | GOLANGCI="bin/golangci-lint" 27 | fi 28 | 29 | function print_install_help() { 30 | echo "Automatic installation failed, you can install golangci-lint v${GOLANGCI_VERSION} manually by running:" 31 | echo " curl -sSfL \"https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh\" | sh -s -- -b \"$(pwd)/bin\" v${GOLANGCI_VERSION}" 32 | echo "It will be installed to \"$(pwd)/bin/golangci-lint\" so that it won't interfere with existing versions (if any)." 33 | exit 1 34 | } 35 | 36 | function install_golangci() { 37 | echo "Installing golangci-lint v${GOLANGCI_VERSION} ..." 38 | echo "It will be installed to \"$(pwd)/bin/golangci-lint\" so that it won't interfere with existing versions (if any)." 39 | curl -sSfL "https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh" | 40 | sh -s -- -b "$(pwd)/bin" v${GOLANGCI_VERSION} || print_install_help 41 | } 42 | 43 | if ! ${GOLANGCI} version >/dev/null 2>&1; then 44 | echo "You don't have golangci-lint installed." 2>&1 45 | install_golangci 46 | $0 "$@" 47 | exit 48 | fi 49 | 50 | CURRENT_GOLANGCI_VERSION="$(${GOLANGCI} version 2>&1)" 51 | CURRENT_GOLANGCI_VERSION="${CURRENT_GOLANGCI_VERSION#*version }" 52 | CURRENT_GOLANGCI_VERSION="${CURRENT_GOLANGCI_VERSION% built*}" 53 | 54 | if [ "${CURRENT_GOLANGCI_VERSION}" != "${GOLANGCI_VERSION}" ]; then 55 | echo "You have golangci-lint v${CURRENT_GOLANGCI_VERSION} installed, but we want v${GOLANGCI_VERSION}" 1>&2 56 | install_golangci 57 | $0 "$@" 58 | exit 59 | fi 60 | 61 | echo "# Running golangci-lint v${CURRENT_GOLANGCI_VERSION}..." 62 | 63 | ${GOLANGCI} run "$@" 64 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net" 8 | "net/http" 9 | "strings" 10 | 11 | "github.com/sirupsen/logrus" 12 | ) 13 | 14 | func send(method string, path string, data string) (string, error) { 15 | logrus.WithFields(logrus.Fields{ 16 | "method": method, 17 | "path": path, 18 | "data": data, 19 | "unix": unixSocketPath, 20 | }).Debug("sending request") 21 | 22 | httpc := http.Client{ 23 | Transport: &http.Transport{ 24 | DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { 25 | conn, err := net.Dial("unix", unixSocketPath) 26 | if err != nil { 27 | logrus.Errorf("failed to connect to unix socket. 1) Do you have adequate permissions? Please re-run as root. 2) Is the daemon running? Have you installed it?") 28 | return nil, err 29 | } 30 | return conn, err 31 | }, 32 | }, 33 | } 34 | 35 | var resp *http.Response 36 | var err error 37 | 38 | switch method { 39 | case "GET": 40 | resp, err = httpc.Get("http://unix" + path) 41 | case "POST": 42 | resp, err = httpc.Post("http://unix"+path, "application/octet-stream", strings.NewReader(data)) 43 | case "PUT": 44 | req, err2 := http.NewRequest("PUT", "http://unix"+path, strings.NewReader(data)) 45 | if err2 != nil { 46 | return "", fmt.Errorf("failed to create request: %w", err) 47 | } 48 | resp, err = httpc.Do(req) 49 | case "DELETE": 50 | req, err2 := http.NewRequest("DELETE", "http://unix"+path, nil) 51 | if err2 != nil { 52 | return "", fmt.Errorf("failed to create request: %w", err) 53 | } 54 | resp, err = httpc.Do(req) 55 | default: 56 | return "", fmt.Errorf("unknown method: %s", method) 57 | } 58 | 59 | if err != nil { 60 | return "", fmt.Errorf("failed to send request: %w", err) 61 | } 62 | 63 | b, err := io.ReadAll(resp.Body) 64 | if err != nil { 65 | return "", fmt.Errorf("failed to read response body: %w", err) 66 | } 67 | body := string(b) 68 | 69 | code := resp.StatusCode 70 | 71 | logrus.WithFields(logrus.Fields{ 72 | "code": code, 73 | "body": body, 74 | }).Debug("got response") 75 | 76 | if code < 200 || code > 299 { 77 | return "", fmt.Errorf("got %d: %s", code, body) 78 | } 79 | 80 | return body, nil 81 | } 82 | 83 | func get(path string) (string, error) { 84 | return send("GET", path, "") 85 | } 86 | 87 | //nolint:unused 88 | func post(path string, data string) (string, error) { 89 | return send("POST", path, data) 90 | } 91 | 92 | func put(path string, data string) (string, error) { 93 | return send("PUT", path, data) 94 | } 95 | 96 | //nolint:unused 97 | func del(path string) (string, error) { 98 | return send("DELETE", path, "") 99 | } 100 | -------------------------------------------------------------------------------- /cmd.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strconv" 7 | 8 | "github.com/distatus/battery" 9 | "github.com/fatih/color" 10 | "github.com/sirupsen/logrus" 11 | "github.com/spf13/cobra" 12 | 13 | "github.com/charlie0129/batt/pkg/version" 14 | ) 15 | 16 | var ( 17 | logLevel = "info" 18 | ) 19 | 20 | var ( 21 | gBasic = "Basic:" 22 | gAdvanced = "Advanced:" 23 | gInstallation = "Installation:" 24 | commandGroups = []string{ 25 | gBasic, 26 | gAdvanced, 27 | } 28 | ) 29 | 30 | // NewCommand . 31 | func NewCommand() *cobra.Command { 32 | cmd := &cobra.Command{ 33 | Use: "batt", 34 | Short: "batt is a tool to control battery charging on Apple Silicon MacBooks", 35 | Long: `batt is a tool to control battery charging on Apple Silicon MacBooks. 36 | 37 | Website: https://github.com/charlie0129/batt`, 38 | SilenceUsage: true, 39 | PersistentPreRunE: func(_ *cobra.Command, _ []string) error { 40 | return setupLogger() 41 | }, 42 | } 43 | 44 | globalFlags := cmd.PersistentFlags() 45 | globalFlags.StringVarP(&logLevel, "log-level", "l", "info", "log level (trace, debug, info, warn, error, fatal, panic)") 46 | globalFlags.StringVar(&configPath, "config", configPath, "config file path") 47 | globalFlags.StringVar(&unixSocketPath, "daemon-socket", unixSocketPath, "batt daemon unix socket path") 48 | 49 | for _, i := range commandGroups { 50 | cmd.AddGroup(&cobra.Group{ 51 | ID: i, 52 | Title: i, 53 | }) 54 | } 55 | 56 | cmd.AddCommand( 57 | NewDaemonCommand(), 58 | NewVersionCommand(), 59 | NewLimitCommand(), 60 | NewDisableCommand(), 61 | NewSetDisableChargingPreSleepCommand(), 62 | NewSetPreventIdleSleepCommand(), 63 | NewStatusCommand(), 64 | NewAdapterCommand(), 65 | NewLowerLimitDeltaCommand(), 66 | NewSetControlMagSafeLEDCommand(), 67 | NewInstallCommand(), 68 | NewUninstallCommand(), 69 | ) 70 | 71 | return cmd 72 | } 73 | 74 | // NewVersionCommand . 75 | func NewVersionCommand() *cobra.Command { 76 | return &cobra.Command{ 77 | Use: "version", 78 | Short: "Print version", 79 | Run: func(cmd *cobra.Command, _ []string) { 80 | cmd.Printf("%s %s\n", version.Version, version.GitCommit) 81 | }, 82 | } 83 | } 84 | 85 | // NewDaemonCommand . 86 | func NewDaemonCommand() *cobra.Command { 87 | cmd := &cobra.Command{ 88 | Use: "daemon", 89 | Hidden: true, 90 | Short: "Run batt daemon in the foreground", 91 | GroupID: gAdvanced, 92 | Run: func(_ *cobra.Command, _ []string) { 93 | logrus.Infof("batt version %s commit %s", version.Version, version.GitCommit) 94 | runDaemon() 95 | }, 96 | } 97 | 98 | f := cmd.Flags() 99 | 100 | f.BoolVar(&alwaysAllowNonRootAccess, "always-allow-non-root-access", false, 101 | "Always allow non-root users to access the daemon.") 102 | 103 | return cmd 104 | } 105 | 106 | // NewLimitCommand . 107 | func NewLimitCommand() *cobra.Command { 108 | return &cobra.Command{ 109 | Use: "limit [percentage]", 110 | Short: "Set upper charge limit", 111 | GroupID: gBasic, 112 | Long: `Set upper charge limit. 113 | 114 | This is a percentage from 10 to 100. 115 | 116 | Setting the limit to 10-99 will enable the battery charge limit. However, setting the limit to 100 will disable the battery charge limit, which is the default behavior of macOS.`, 117 | RunE: func(_ *cobra.Command, args []string) error { 118 | if len(args) != 1 { 119 | return fmt.Errorf("invalid number of arguments") 120 | } 121 | 122 | ret, err := put("/limit", args[0]) 123 | if err != nil { 124 | return fmt.Errorf("failed to set limit: %v", err) 125 | } 126 | 127 | if ret != "" { 128 | logrus.Infof("daemon responded: %s", ret) 129 | } 130 | 131 | logrus.Infof("successfully set battery charge limit to %s%%", args[0]) 132 | 133 | return nil 134 | }, 135 | } 136 | } 137 | 138 | // NewDisableCommand . 139 | func NewDisableCommand() *cobra.Command { 140 | return &cobra.Command{ 141 | Use: "disable", 142 | Short: "Disable batt", 143 | GroupID: gBasic, 144 | Long: `Disable batt. 145 | 146 | Stop batt from controlling battery charging. This will allow your Mac to charge to 100%.`, 147 | RunE: func(_ *cobra.Command, _ []string) error { 148 | ret, err := put("/limit", "100") 149 | if err != nil { 150 | return fmt.Errorf("failed to disable batt: %v", err) 151 | } 152 | 153 | if ret != "" { 154 | logrus.Infof("daemon responded: %s", ret) 155 | } 156 | 157 | logrus.Infof("successfully disabled batt. Charge limit has been reset to 100%%. To re-enable batt, just set a charge limit using \"batt limit\".") 158 | 159 | return nil 160 | }, 161 | } 162 | } 163 | 164 | // NewSetPreventIdleSleepCommand . 165 | func NewSetPreventIdleSleepCommand() *cobra.Command { 166 | cmd := &cobra.Command{ 167 | Use: "prevent-idle-sleep", 168 | Short: "Set whether to prevent idle sleep during a charging session", 169 | GroupID: gAdvanced, 170 | Long: `Set whether to prevent idle sleep during a charging session. 171 | 172 | Due to macOS limitations, batt will be paused when your computer goes to sleep. As a result, when you are in a charging session and your computer goes to sleep, there is no way for batt to stop charging (since batt is paused by macOS) and the battery will charge to 100%. This option, together with disable-charging-pre-sleep, will prevent this from happening. 173 | 174 | This option tells macOS NOT to go to sleep when the computer is in a charging session, so batt can continue to work until charging is finished. Note that it will only prevent **idle** sleep, when 1) charging is active 2) battery charge limit is enabled. So your computer can go to sleep as soon as a charging session is completed. 175 | 176 | However, this options does not prevent manual sleep (limitation of macOS). For example, if you manually put your computer to sleep (by choosing the Sleep option in the top-left Apple menu) or close the lid, batt will still be paused and the issue mentioned above will still happen. This is where disable-charging-pre-sleep comes in.`, 177 | } 178 | 179 | cmd.AddCommand( 180 | &cobra.Command{ 181 | Use: "enable", 182 | Short: "Prevent idle sleep during a charging session", 183 | RunE: func(_ *cobra.Command, _ []string) error { 184 | ret, err := put("/prevent-idle-sleep", "true") 185 | if err != nil { 186 | return fmt.Errorf("failed to set prevent idle sleep: %v", err) 187 | } 188 | 189 | if ret != "" { 190 | logrus.Infof("daemon responded: %s", ret) 191 | } 192 | 193 | logrus.Infof("successfully enabled idle sleep prevention") 194 | 195 | return nil 196 | }, 197 | }, 198 | &cobra.Command{ 199 | Use: "disable", 200 | Short: "Do not prevent idle sleep during a charging session", 201 | RunE: func(_ *cobra.Command, _ []string) error { 202 | ret, err := put("/prevent-idle-sleep", "false") 203 | if err != nil { 204 | return fmt.Errorf("failed to set prevent idle sleep: %v", err) 205 | } 206 | 207 | if ret != "" { 208 | logrus.Infof("daemon responded: %s", ret) 209 | } 210 | 211 | logrus.Infof("successfully disabled idle sleep prevention") 212 | 213 | return nil 214 | }, 215 | }, 216 | ) 217 | 218 | return cmd 219 | } 220 | 221 | // NewSetDisableChargingPreSleepCommand . 222 | func NewSetDisableChargingPreSleepCommand() *cobra.Command { 223 | cmd := &cobra.Command{ 224 | Use: "disable-charging-pre-sleep", 225 | Short: "Set whether to disable charging before sleep if charge limit is enabled", 226 | GroupID: gAdvanced, 227 | Long: `Set whether to disable charging before sleep if charge limit is enabled. 228 | 229 | As described in preventing-idle-sleep, batt will be paused by macOS when your computer goes to sleep, and there is no way for batt to continue controlling battery charging. This option will disable charging just before sleep, so your computer will not overcharge during sleep, even if the battery charge is below the limit.`, 230 | } 231 | 232 | cmd.AddCommand( 233 | &cobra.Command{ 234 | Use: "enable", 235 | Short: "Disable charging before sleep during a charging session", 236 | RunE: func(_ *cobra.Command, _ []string) error { 237 | ret, err := put("/disable-charging-pre-sleep", "true") 238 | if err != nil { 239 | return fmt.Errorf("failed to set disable charging pre sleep: %v", err) 240 | } 241 | 242 | if ret != "" { 243 | logrus.Infof("daemon responded: %s", ret) 244 | } 245 | 246 | logrus.Infof("successfully enabled disable-charging-pre-sleep") 247 | 248 | return nil 249 | }, 250 | }, 251 | &cobra.Command{ 252 | Use: "disable", 253 | Short: "Do not disable charging before sleep during a charging session", 254 | RunE: func(_ *cobra.Command, _ []string) error { 255 | ret, err := put("/disable-charging-pre-sleep", "false") 256 | if err != nil { 257 | return fmt.Errorf("failed to set disable charging pre sleep: %v", err) 258 | } 259 | 260 | if ret != "" { 261 | logrus.Infof("daemon responded: %s", ret) 262 | } 263 | 264 | logrus.Infof("successfully disabled disable-charging-pre-sleep") 265 | 266 | return nil 267 | }, 268 | }, 269 | ) 270 | 271 | return cmd 272 | } 273 | 274 | // NewAdapterCommand . 275 | func NewAdapterCommand() *cobra.Command { 276 | cmd := &cobra.Command{ 277 | Use: "adapter", 278 | Short: "Enable or disable power input", 279 | GroupID: gBasic, 280 | Long: `Cut or restore power from the wall. This has the same effect as unplugging/plugging the power adapter, even if the adapter is physically plugged in. 281 | 282 | This is useful when you want to use your battery to lower the battery charge, but you don't want to unplug the power adapter. 283 | 284 | NOTE: if you are using Clamshell mode (using a Mac laptop with an external monitor and the lid closed), *cutting power will cause your Mac to go to sleep*. This is a limitation of macOS. There are ways to prevent this, but it is not recommended for most users.`, 285 | } 286 | 287 | cmd.AddCommand( 288 | &cobra.Command{ 289 | Use: "disable", 290 | Short: "Disable power adapter", 291 | RunE: func(_ *cobra.Command, _ []string) error { 292 | ret, err := put("/adapter", "false") 293 | if err != nil { 294 | return fmt.Errorf("failed to disable power adapter: %v", err) 295 | } 296 | 297 | if ret != "" { 298 | logrus.Infof("daemon responded: %s", ret) 299 | } 300 | 301 | logrus.Infof("successfully disabled power adapter") 302 | 303 | return nil 304 | }, 305 | }, 306 | &cobra.Command{ 307 | Use: "enable", 308 | Short: "Enable power adapter", 309 | RunE: func(_ *cobra.Command, _ []string) error { 310 | ret, err := put("/adapter", "true") 311 | if err != nil { 312 | return fmt.Errorf("failed to enable power adapter: %v", err) 313 | } 314 | 315 | if ret != "" { 316 | logrus.Infof("daemon responded: %s", ret) 317 | } 318 | 319 | logrus.Infof("successfully enabled power adapter") 320 | 321 | return nil 322 | }, 323 | }, 324 | &cobra.Command{ 325 | Use: "status", 326 | Short: "Get the current status of power adapter", 327 | RunE: func(_ *cobra.Command, _ []string) error { 328 | ret, err := get("/adapter") 329 | if err != nil { 330 | return fmt.Errorf("failed to get power adapter status: %v", err) 331 | } 332 | 333 | switch ret { 334 | case "true": 335 | logrus.Infof("power adapter is enabled") 336 | case "false": 337 | logrus.Infof("power adapter is disabled") 338 | default: 339 | logrus.Errorf("unknown power adapter status") 340 | } 341 | 342 | return nil 343 | }, 344 | }, 345 | ) 346 | 347 | return cmd 348 | } 349 | 350 | // NewStatusCommand . 351 | func NewStatusCommand() *cobra.Command { 352 | return &cobra.Command{ 353 | Use: "status", 354 | GroupID: gBasic, 355 | Short: "Get the current status of batt", 356 | Long: `Get batt status, battery info, and configuration.`, 357 | RunE: func(cmd *cobra.Command, _ []string) error { 358 | // Get various info first. 359 | ret, err := get("/charging") 360 | if err != nil { 361 | return fmt.Errorf("failed to get charging status: %v", err) 362 | } 363 | charging, err := strconv.ParseBool(ret) 364 | if err != nil { 365 | return err 366 | } 367 | 368 | ret, err = get("/plugged-in") 369 | if err != nil { 370 | return fmt.Errorf("failed to check if you are plugged in: %v", err) 371 | } 372 | pluggedIn, err := strconv.ParseBool(ret) 373 | if err != nil { 374 | return err 375 | } 376 | 377 | ret, err = get("/adapter") 378 | if err != nil { 379 | return fmt.Errorf("failed to get power adapter status: %v", err) 380 | } 381 | adapter, err := strconv.ParseBool(ret) 382 | if err != nil { 383 | return err 384 | } 385 | 386 | ret, err = get("/current-charge") 387 | if err != nil { 388 | return fmt.Errorf("failed to get current charge: %v", err) 389 | } 390 | currentCharge, err := strconv.Atoi(ret) 391 | if err != nil { 392 | return fmt.Errorf("failed to unmarshal current charge: %v", err) 393 | } 394 | 395 | ret, err = get("/battery-info") 396 | if err != nil { 397 | return fmt.Errorf("failed to get battery info: %v", err) 398 | } 399 | var bat battery.Battery 400 | err = json.Unmarshal([]byte(ret), &bat) 401 | if err != nil { 402 | return fmt.Errorf("failed to unmarshal battery info: %v", err) 403 | } 404 | 405 | ret, err = get("/config") 406 | if err != nil { 407 | return fmt.Errorf("failed to get config: %v", err) 408 | } 409 | 410 | conf := Config{} 411 | err = json.Unmarshal([]byte(ret), &conf) 412 | if err != nil { 413 | return fmt.Errorf("failed to unmarshal config: %v", err) 414 | } 415 | 416 | // Charging status. 417 | cmd.Println(bold("Charging status:")) 418 | 419 | additionalMsg := " (refreshes can take up to 2 minutes)" 420 | if charging { 421 | cmd.Println(" Allow charging: " + bool2Text(true) + additionalMsg) 422 | cmd.Print(" Your Mac will charge") 423 | if !pluggedIn { 424 | cmd.Print(", but you are not plugged in yet.") // not plugged in but charging is allowed. 425 | } else { 426 | cmd.Print(".") // plugged in and charging is allowed. 427 | } 428 | cmd.Println() 429 | } else if conf.Limit < 100 { 430 | cmd.Println(" Allow charging: " + bool2Text(false) + additionalMsg) 431 | cmd.Print(" Your Mac will not charge") 432 | if pluggedIn { 433 | cmd.Print(" even if you plug in") 434 | } 435 | low := conf.Limit - conf.LowerLimitDelta 436 | if currentCharge >= conf.Limit { 437 | cmd.Print(", because your current charge is above the limit.") 438 | } else if currentCharge < conf.Limit && currentCharge >= low { 439 | cmd.Print(", because your current charge is above the lower limit. Charging will be allowed after current charge drops below the lower limit.") 440 | } 441 | if pluggedIn && currentCharge < low { 442 | if adapter { 443 | cmd.Print(". However, if no manual intervention is involved, charging should be allowed soon. Wait 2 minutes and come back.") 444 | } else { 445 | cmd.Print(", because adapter is disabled.") 446 | } 447 | } 448 | cmd.Println() 449 | } 450 | 451 | if adapter { 452 | cmd.Println(" Use power adapter: " + bool2Text(true)) 453 | cmd.Println(" Your Mac will use power from the wall (to operate or charge), if it is plugged in.") 454 | } else { 455 | cmd.Println(" Use power adapter: " + bool2Text(false)) 456 | cmd.Println(" Your Mac will not use power from the wall (to operate or charge), even if it is plugged in.") 457 | } 458 | 459 | cmd.Println() 460 | 461 | // Battery Info. 462 | cmd.Println(bold("Battery status:")) 463 | 464 | cmd.Printf(" Current charge: %s\n", bold("%d%%", currentCharge)) 465 | 466 | state := "not charging" 467 | switch bat.State { 468 | case battery.Charging: 469 | state = color.GreenString("charging") 470 | case battery.Discharging: 471 | state = color.RedString("discharging") 472 | case battery.Full: 473 | state = "full" 474 | } 475 | cmd.Printf(" State: %s\n", bold("%s", state)) 476 | cmd.Printf(" Full capacity: %s\n", bold("%.1f Wh", bat.Design/1e3)) 477 | cmd.Printf(" Charge rate: %s\n", bold("%.1f W", bat.ChargeRate/1e3)) 478 | cmd.Printf(" Voltage: %s\n", bold("%.2f V", bat.DesignVoltage)) 479 | 480 | cmd.Println() 481 | 482 | // Config. 483 | cmd.Println(bold("Batt configuration:")) 484 | if conf.Limit < 100 { 485 | cmd.Printf(" Upper limit: %s\n", bold("%d%%", conf.Limit)) 486 | cmd.Printf(" Lower limit: %s (%d-%d)\n", bold("%d%%", conf.Limit-conf.LowerLimitDelta), conf.Limit, conf.LowerLimitDelta) 487 | } else { 488 | cmd.Printf(" Charge limit: %s\n", bold("100%% (batt disabled)")) 489 | } 490 | cmd.Printf(" Prevent idle-sleep when charging: %s\n", bool2Text(conf.PreventIdleSleep)) 491 | cmd.Printf(" Disable charging before sleep if charge limit is enabled: %s\n", bool2Text(conf.DisableChargingPreSleep)) 492 | cmd.Printf(" Allow non-root users to access the daemon: %s\n", bool2Text(conf.AllowNonRootAccess)) 493 | cmd.Printf(" Control MagSafe LED: %s\n", bool2Text(conf.ControlMagSafeLED)) 494 | return nil 495 | }, 496 | } 497 | } 498 | 499 | // NewLowerLimitDeltaCommand . 500 | func NewLowerLimitDeltaCommand() *cobra.Command { 501 | cmd := &cobra.Command{ 502 | Use: "lower-limit-delta", 503 | Short: "Set the delta between lower and upper charge limit", 504 | GroupID: gAdvanced, 505 | Long: `Set the delta between lower and upper charge limit. 506 | 507 | When you set a charge limit, for example, on a Lenovo ThinkPad, you can set two percentages. The first one is the upper limit, and the second one is the lower limit. When the battery charge is above the upper limit, the computer will stop charging. When the battery charge is below the lower limit, the computer will start charging. If the battery charge is between the two limits, the computer will keep whatever charging state it is in. 508 | 509 | batt have similar features. The charge limit you have set (using 'batt limit') will be used as the upper limit. By default, The lower limit will be set to 2% less than the upper limit. Same as using 'batt lower-limit-delta 2'. To customize the lower limit, use 'batt lower-limit-delta'. 510 | 511 | For example, if you want to set the lower limit to be 5% less than the upper limit, run 'sudo batt lower-limit-delta 5'. By doing this, if you have your charge (upper) limit set to 60%, the lower limit will be 55%.`, 512 | RunE: func(_ *cobra.Command, args []string) error { 513 | if len(args) != 1 { 514 | return fmt.Errorf("invalid number of arguments") 515 | } 516 | 517 | delta, err := strconv.Atoi(args[0]) 518 | if err != nil { 519 | return fmt.Errorf("invalid delta: %v", err) 520 | } 521 | 522 | ret, err := put("/lower-limit-delta", strconv.Itoa(delta)) 523 | if err != nil { 524 | return fmt.Errorf("failed to set lower limit delta: %v", err) 525 | } 526 | 527 | if ret != "" { 528 | logrus.Infof("daemon responded: %s", ret) 529 | } 530 | 531 | logrus.Infof("successfully set lower limit delta to %d%%", delta) 532 | 533 | return nil 534 | }, 535 | } 536 | 537 | return cmd 538 | } 539 | 540 | // NewSetControlMagSafeLEDCommand . 541 | func NewSetControlMagSafeLEDCommand() *cobra.Command { 542 | cmd := &cobra.Command{ 543 | Use: "magsafe-led", 544 | Short: "Control MagSafe LED according to battery charging status", 545 | GroupID: gAdvanced, 546 | Long: `This option can make the MagSafe LED on your MacBook change color according to the charging status. For example: 547 | 548 | - Green: charge limit is reached and charging is stopped. 549 | - Orange: charging is in progress. 550 | - Off: just woken up from sleep, charing is disabled and batt is waiting before controlling charging. 551 | 552 | Note that you must have a MagSafe LED on your MacBook to use this feature.`, 553 | } 554 | 555 | cmd.AddCommand( 556 | &cobra.Command{ 557 | Use: "enable", 558 | Short: "Control MagSafe LED according to battery charging status", 559 | RunE: func(_ *cobra.Command, _ []string) error { 560 | ret, err := put("/magsafe-led", "true") 561 | if err != nil { 562 | return fmt.Errorf("failed to set magsafe: %v", err) 563 | } 564 | 565 | if ret != "" { 566 | logrus.Infof("daemon responded: %s", ret) 567 | } 568 | 569 | logrus.Infof("successfully enabled magsafe led controlling") 570 | 571 | return nil 572 | }, 573 | }, 574 | &cobra.Command{ 575 | Use: "disable", 576 | Short: "Do not control MagSafe LED", 577 | RunE: func(_ *cobra.Command, _ []string) error { 578 | ret, err := put("/magsafe-led", "false") 579 | if err != nil { 580 | return fmt.Errorf("failed to set magsafe: %v", err) 581 | } 582 | 583 | if ret != "" { 584 | logrus.Infof("daemon responded: %s", ret) 585 | } 586 | 587 | logrus.Infof("successfully disabled magsafe led controlling") 588 | 589 | return nil 590 | }, 591 | }, 592 | ) 593 | 594 | return cmd 595 | } 596 | 597 | func bool2Text(b bool) string { 598 | if b { 599 | return color.New(color.Bold, color.FgGreen).Sprint("✔") 600 | } 601 | return color.New(color.Bold, color.FgRed).Sprint("✘") 602 | } 603 | 604 | func bold(format string, a ...interface{}) string { 605 | return color.New(color.Bold).Sprintf(format, a...) 606 | } 607 | -------------------------------------------------------------------------------- /conf.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "os" 8 | 9 | "github.com/sirupsen/logrus" 10 | ) 11 | 12 | // Config is the configuration of batt. 13 | type Config struct { 14 | // Limit is the battery charge limit in percentage, when Maintain is enabled. 15 | // batt will keep the battery charge around this limit. Note that if your 16 | // current battery charge is higher than the limit, it will simply stop 17 | // charging. 18 | Limit int `json:"limit"` 19 | PreventIdleSleep bool `json:"preventIdleSleep"` 20 | DisableChargingPreSleep bool `json:"disableChargingPreSleep"` 21 | AllowNonRootAccess bool `json:"allowNonRootAccess"` 22 | LowerLimitDelta int `json:"lowerLimitDelta"` 23 | ControlMagSafeLED bool `json:"controlMagSafeLED"` 24 | } 25 | 26 | var ( 27 | configPath = "/etc/batt.json" 28 | defaultConfig = Config{ 29 | Limit: 60, 30 | PreventIdleSleep: true, 31 | DisableChargingPreSleep: true, 32 | AllowNonRootAccess: false, 33 | LowerLimitDelta: 2, 34 | // There are Macs without MagSafe LED. We only do checks when the user 35 | // explicitly enables this feature. In the future, we might add a check 36 | // that disables this feature if the Mac does not have a MagSafe LED. 37 | ControlMagSafeLED: false, 38 | } 39 | ) 40 | 41 | var config = defaultConfig 42 | 43 | func saveConfig() error { 44 | b, err := json.MarshalIndent(config, "", " ") 45 | if err != nil { 46 | return err 47 | } 48 | return os.WriteFile(configPath, b, 0644) 49 | } 50 | 51 | func resetConfig() error { 52 | config = defaultConfig 53 | 54 | logrus.WithFields(logrus.Fields{ 55 | "config": defaultConfig, 56 | "config_file": configPath, 57 | }).Warn("resetting config file to default") 58 | 59 | err := saveConfig() 60 | return err 61 | } 62 | 63 | func loadConfig() error { 64 | // Check if config file exists 65 | if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) { 66 | logrus.WithFields(logrus.Fields{ 67 | "config_file": configPath, 68 | }).Warn("config file does not exist") 69 | 70 | err = resetConfig() 71 | if err != nil { 72 | return err 73 | } 74 | } 75 | 76 | b, err := os.ReadFile(configPath) 77 | if err != nil { 78 | return err 79 | } 80 | 81 | if len(bytes.TrimSpace(b)) == 0 { 82 | logrus.WithFields(logrus.Fields{ 83 | "config_file": configPath, 84 | "config_bytes": b, 85 | }).Warn("config file is empty") 86 | 87 | err = resetConfig() 88 | if err != nil { 89 | return err 90 | } 91 | 92 | b, err = os.ReadFile(configPath) 93 | if err != nil { 94 | return err 95 | } 96 | } 97 | 98 | return json.Unmarshal(b, &config) 99 | } 100 | -------------------------------------------------------------------------------- /daemon.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net" 7 | "net/http" 8 | "os" 9 | "os/signal" 10 | "syscall" 11 | "time" 12 | 13 | "github.com/gin-gonic/gin" 14 | "github.com/sirupsen/logrus" 15 | 16 | "github.com/charlie0129/batt/pkg/smc" 17 | ) 18 | 19 | var ( 20 | smcConn *smc.AppleSMC 21 | unixSocketPath = "/var/run/batt.sock" 22 | ) 23 | 24 | func setupRoutes() *gin.Engine { 25 | gin.SetMode(gin.ReleaseMode) 26 | 27 | router := gin.New() 28 | router.Use(gin.Recovery()) 29 | router.Use(ginLogger(logrus.StandardLogger())) 30 | router.GET("/config", getConfig) 31 | router.PUT("/config", setConfig) // Should not be called by user. 32 | router.GET("/limit", getLimit) 33 | router.PUT("/limit", setLimit) 34 | router.PUT("/lower-limit-delta", setLowerLimitDelta) 35 | router.PUT("/prevent-idle-sleep", setPreventIdleSleep) 36 | router.PUT("/disable-charging-pre-sleep", setDisableChargingPreSleep) 37 | router.PUT("/adapter", setAdapter) 38 | router.GET("/adapter", getAdapter) 39 | router.GET("/charging", getCharging) 40 | router.GET("/battery-info", getBatteryInfo) 41 | router.PUT("/magsafe-led", setControlMagSafeLED) 42 | router.GET("/current-charge", getCurrentCharge) 43 | router.GET("/plugged-in", getPluggedIn) 44 | 45 | return router 46 | } 47 | 48 | var ( 49 | alwaysAllowNonRootAccess = false 50 | ) 51 | 52 | func runDaemon() { 53 | router := setupRoutes() 54 | 55 | err := loadConfig() 56 | if err != nil { 57 | logrus.Fatalf("failed to parse config during startup: %v", err) 58 | } 59 | logrus.Infof("config loaded: %#v", config) 60 | 61 | if alwaysAllowNonRootAccess { 62 | config.AllowNonRootAccess = true 63 | logrus.Info("alwaysAllowNonRootAccess is set to true, allowing non-root access") 64 | } 65 | 66 | // Receive SIGHUP to reload config 67 | go func() { 68 | sigc := make(chan os.Signal, 1) 69 | signal.Notify(sigc, syscall.SIGHUP) 70 | for range sigc { 71 | err := loadConfig() 72 | if err != nil { 73 | logrus.Errorf("failed to reload config: %v", err) 74 | } 75 | logrus.Infof("config reloaded: %#v", config) 76 | } 77 | }() 78 | 79 | srv := &http.Server{ 80 | Handler: router, 81 | } 82 | 83 | // Create the socket to listen on: 84 | l, err := net.Listen("unix", unixSocketPath) 85 | if err != nil { 86 | logrus.Fatal(err) 87 | return 88 | } 89 | 90 | if config.AllowNonRootAccess { 91 | logrus.Infof("non-root access is allowed, chaning permissions of %s to 0777", unixSocketPath) 92 | err = os.Chmod(unixSocketPath, 0777) 93 | if err != nil { 94 | logrus.Fatal(err) 95 | return 96 | } 97 | } 98 | 99 | // Serve HTTP on unix socket 100 | go func() { 101 | logrus.Infof("http server listening on %s", l.Addr().String()) 102 | if err := srv.Serve(l); err != nil && !errors.Is(err, http.ErrServerClosed) { 103 | logrus.Fatal(err) 104 | } 105 | }() 106 | 107 | // Listen to system sleep notifications. 108 | go func() { 109 | err := listenNotifications() 110 | if err != nil { 111 | logrus.Errorf("failed to listen to system sleep notifications: %v", err) 112 | os.Exit(1) 113 | } 114 | }() 115 | 116 | // Open Apple SMC for read/writing 117 | smcConn = smc.New() 118 | if err := smcConn.Open(); err != nil { 119 | logrus.Fatal(err) 120 | } 121 | 122 | go func() { 123 | logrus.Debugln("main loop starts") 124 | 125 | infiniteLoop() 126 | 127 | logrus.Errorf("main loop exited unexpectedly") 128 | }() 129 | 130 | // Handle common process-killing signals, so we can gracefully shut down: 131 | sigc := make(chan os.Signal, 1) 132 | signal.Notify(sigc, syscall.SIGINT, syscall.SIGTERM) 133 | // Wait for a SIGINT or SIGTERM: 134 | sig := <-sigc 135 | logrus.Infof("caught signal \"%s\": shutting down.", sig) 136 | 137 | logrus.Info("shutting down http server") 138 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 139 | err = srv.Shutdown(ctx) 140 | if err != nil { 141 | logrus.Errorf("failed to shutdown http server: %v", err) 142 | } 143 | cancel() 144 | 145 | logrus.Info("stopping listening notifications") 146 | stopListeningNotifications() 147 | 148 | logrus.Info("closing smc connection") 149 | err = smcConn.Close() 150 | if err != nil { 151 | logrus.Errorf("failed to close smc connection: %v", err) 152 | } 153 | 154 | logrus.Info("saving config") 155 | err = saveConfig() 156 | if err != nil { 157 | logrus.Errorf("failed to save config: %v", err) 158 | } 159 | 160 | logrus.Info("exiting") 161 | os.Exit(0) 162 | } 163 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/charlie0129/batt 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/charlie0129/gosmc v0.0.0-20250206014004-02656b3859e0 7 | github.com/distatus/battery v0.10.0 8 | github.com/fatih/color v1.15.0 9 | github.com/gin-gonic/gin v1.9.1 10 | github.com/sirupsen/logrus v1.9.3 11 | github.com/spf13/cobra v1.7.0 12 | ) 13 | 14 | require ( 15 | github.com/bytedance/sonic v1.9.1 // indirect 16 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect 17 | github.com/gabriel-vasile/mimetype v1.4.2 // indirect 18 | github.com/gin-contrib/sse v0.1.0 // indirect 19 | github.com/go-playground/locales v0.14.1 // indirect 20 | github.com/go-playground/universal-translator v0.18.1 // indirect 21 | github.com/go-playground/validator/v10 v10.14.1 // indirect 22 | github.com/goccy/go-json v0.10.2 // indirect 23 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 24 | github.com/json-iterator/go v1.1.12 // indirect 25 | github.com/klauspost/cpuid/v2 v2.2.5 // indirect 26 | github.com/leodido/go-urn v1.2.4 // indirect 27 | github.com/mattn/go-colorable v0.1.13 // indirect 28 | github.com/mattn/go-isatty v0.0.19 // indirect 29 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 30 | github.com/modern-go/reflect2 v1.0.2 // indirect 31 | github.com/pelletier/go-toml/v2 v2.0.8 // indirect 32 | github.com/spf13/pflag v1.0.5 // indirect 33 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 34 | github.com/ugorji/go/codec v1.2.11 // indirect 35 | golang.org/x/arch v0.3.0 // indirect 36 | golang.org/x/crypto v0.10.0 // indirect 37 | golang.org/x/net v0.11.0 // indirect 38 | golang.org/x/sys v0.9.0 // indirect 39 | golang.org/x/text v0.10.0 // indirect 40 | google.golang.org/protobuf v1.30.0 // indirect 41 | gopkg.in/yaml.v3 v3.0.1 // indirect 42 | howett.net/plist v1.0.0 // indirect 43 | ) 44 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= 2 | github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= 3 | github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= 4 | github.com/charlie0129/gosmc v0.0.0-20250206014004-02656b3859e0 h1:FIoAY0O0Bern/X6eLdvI1vEaLGrHreJIWfUlTBBRezc= 5 | github.com/charlie0129/gosmc v0.0.0-20250206014004-02656b3859e0/go.mod h1:R5W0w4VE842ziiT76KVicD26UzkM7Qs5kimJ6JVXo3E= 6 | github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= 7 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= 8 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= 9 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 10 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/distatus/battery v0.10.0 h1:YbizvmV33mqqC1fPCAEaQGV3bBhfYOfM+2XmL+mvt5o= 14 | github.com/distatus/battery v0.10.0/go.mod h1:STnSvFLX//eEpkaN7qWRxCWxrWOcssTDgnG4yqq9BRE= 15 | github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= 16 | github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= 17 | github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= 18 | github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= 19 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= 20 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 21 | github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= 22 | github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= 23 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 24 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 25 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 26 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 27 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 28 | github.com/go-playground/validator/v10 v10.14.1 h1:9c50NUPC30zyuKprjL3vNZ0m5oG+jU0zvx4AqHGnv4k= 29 | github.com/go-playground/validator/v10 v10.14.1/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= 30 | github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= 31 | github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 32 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 33 | github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= 34 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 35 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 36 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 37 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 38 | github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 39 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 40 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 41 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 42 | github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= 43 | github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 44 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 45 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 46 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 47 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 48 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 49 | github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= 50 | github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= 51 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 52 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 53 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 54 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= 55 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 56 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 57 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 58 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 59 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 60 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 61 | github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= 62 | github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= 63 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 64 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 65 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 66 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 67 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 68 | github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= 69 | github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= 70 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 71 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 72 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 73 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 74 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 75 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 76 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 77 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 78 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 79 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 80 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 81 | github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= 82 | github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 83 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 84 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 85 | github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= 86 | github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 87 | golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 88 | golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= 89 | golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 90 | golang.org/x/crypto v0.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM= 91 | golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I= 92 | golang.org/x/net v0.11.0 h1:Gi2tvZIJyBtO9SDr1q9h5hEQCp/4L2RQ+ar0qjx2oNU= 93 | golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ= 94 | golang.org/x/sys v0.0.0-20190912141932-bc967efca4b8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 95 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 96 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 97 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 98 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 99 | golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s= 100 | golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 101 | golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58= 102 | golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 103 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 104 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 105 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 106 | google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= 107 | google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 108 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 109 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 110 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 111 | gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= 112 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 113 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 114 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 115 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 116 | howett.net/plist v0.0.0-20181124034731-591f970eefbb/go.mod h1:vMygbs4qMhSZSc4lCUl2OEE+rDiIIJAIdR4m7MiMcm0= 117 | howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= 118 | howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= 119 | rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= 120 | -------------------------------------------------------------------------------- /hack/cc.chlc.batt.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | KeepAlive 6 | 7 | Label 8 | cc.chlc.batt 9 | 10 | ProcessType 11 | Interactive 12 | ProgramArguments 13 | 14 | /path/to/batt 15 | daemon 16 | --log-level=debug 17 | 18 | RunAtLoad 19 | 20 | StandardErrorPath 21 | /tmp/batt.log 22 | StandardOutPath 23 | /tmp/batt.log 24 | 25 | 26 | -------------------------------------------------------------------------------- /hack/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o pipefail 5 | 6 | bold="\033[1m" 7 | reset="\033[0m" 8 | 9 | # must run on Apple Silicon 10 | if [[ ! $(sysctl -n machdep.cpu.brand_string) =~ "Apple" ]]; then 11 | echo "This script must be run on Apple Silicon." 12 | exit 1 13 | fi 14 | 15 | # must have curl 16 | if ! command -v curl >/dev/null; then 17 | echo "curl is required" 18 | exit 1 19 | fi 20 | 21 | if [[ "$1" == "--help" || "$1" == "-h" ]]; then 22 | echo "This script installs batt on your macOS." 23 | echo "Usage: $0 [-y]" 24 | echo " -y: auto confirm" 25 | echo "Environment variables:" 26 | echo " PREFIX: install location (default: /usr/local/bin)" 27 | echo " VERSION: version to install (default: latest stable release)" 28 | exit 0 29 | fi 30 | 31 | # check -y 32 | if [[ "$1" == "-y" ]]; then 33 | AUTO_CONFIRM=true 34 | fi 35 | 36 | confirm() { 37 | if [[ "$AUTO_CONFIRM" == "true" ]]; then 38 | return 0 39 | fi 40 | while true; do 41 | echo -n "$1 [y/n]: " 42 | read -r -n 1 REPLY 43 | case $REPLY in 44 | [yY]) 45 | echo 46 | return 0 47 | ;; 48 | [nN]) 49 | echo 50 | return 1 51 | ;; 52 | *) printf " is invalid. Press 'y' to continue; 'n' to exit. \n" ;; 53 | esac 54 | done 55 | } 56 | 57 | info() { 58 | echo -e "$(date +'%Y-%m-%d %H:%M:%S') \033[34m[INFO]\033[0m $*" 59 | } 60 | 61 | # If the full path to batt has Homebrew prefix ("/opt"), stop here. 62 | if which batt 2>/dev/null | grep -q /opt; then 63 | echo "You have batt installed via Homebrew. Please use Homebrew to upgrade batt:" 64 | echo " - brew update" 65 | echo " - sudo brew services stop batt" 66 | echo " - brew upgrade batt" 67 | echo " - sudo brew services start batt" 68 | echo "If you want to use this script to install batt, please uninstall Homebrew-installed batt first by:" 69 | echo " - sudo brew services stop batt" 70 | echo " - brew uninstall batt" 71 | echo " - sudo rm -rf /opt/homebrew/Cellar/batt" 72 | exit 1 73 | fi 74 | 75 | if [[ -z "$VERSION" ]]; then 76 | tarball_suffix="darwin-arm64.tar.gz" 77 | 78 | info "Querying latest batt release..." 79 | # jq is intentionally not used here because it is not available on macOS by default 80 | res=$(curl -fsSL https://api.github.com/repos/charlie0129/batt/releases/latest) 81 | tarball_url=$(echo "$res" | 82 | grep -o "browser_download_url.*$tarball_suffix" | 83 | grep -o "https.*") 84 | version=$(echo "$res" | grep -o "tag_name.*" | grep -o "\"v.*\"") 85 | version=${version//\"/} 86 | info "Latest stable version is ${version}." 87 | else 88 | version="$VERSION" 89 | tarball_url="https://github.com/charlie0129/batt/releases/download/$version/batt-$version-darwin-arm64.tar.gz" 90 | fi 91 | 92 | launch_daemon="/Library/LaunchDaemons/cc.chlc.batt.plist" 93 | 94 | # Uninstall old versions (if present) 95 | if [[ -f "$launch_daemon" ]]; then 96 | echo "You have old versions of batt installed, which need to be uninstalled before installing the latest version. We will uninstall it for you now." 97 | confirm "Is this OK?" || exit 0 98 | info "Stopping old versions of batt..." 99 | sudo launchctl unload "$launch_daemon" 100 | sudo rm -f "$launch_daemon" 101 | old_batt_bin="$(which batt || true)" 102 | if [[ -f "$old_batt_bin" ]]; then 103 | info "Removing old versions of batt..." 104 | sudo rm -f "$old_batt_bin" 105 | fi 106 | fi 107 | 108 | if [[ -z "$PREFIX" ]]; then 109 | PREFIX="/usr/local/bin" 110 | fi 111 | 112 | echo -e "Will install batt ${bold}${version}${reset} to ${bold}$PREFIX${reset} (to change install location, set \$PREFIX environment variable)." 113 | confirm "Ready to install?" || exit 0 114 | info "Downloading batt ${version} from $tarball_url and installing to $PREFIX..." 115 | sudo mkdir -p "$PREFIX" 116 | curl -fsSL "$tarball_url" | sudo tar -xzC "$PREFIX" batt 117 | sudo xattr -r -d com.apple.quarantine "$PREFIX/batt" 118 | 119 | install_cmd="sudo $PREFIX/batt install --allow-non-root-access" 120 | info "Installing batt..." 121 | echo "- $install_cmd" 122 | $install_cmd 123 | 124 | info "Installation finished." 125 | echo "Further instructions:" 126 | echo '- If you see an alert says "batt cannot be opened because XXX", please go to System Preferences -> Security & Privacy -> General -> Open Anyway.' 127 | echo -e "- Be sure to ${bold}disable${reset} macOS's optimized charging: Go to System Preferences -> Battery -> uncheck Optimized battery charging." 128 | echo '- To set charge limit to 80%, run "batt limit 80".' 129 | echo '- To see batt help: run "batt help".' 130 | echo '- To see disable charge limit: run "batt disable".' 131 | echo '- To uninstall: run "sudo batt uninstall" and follow the instructions.' 132 | echo '- To upgrade: just run this script again when a new version is released.' 133 | -------------------------------------------------------------------------------- /handler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/distatus/battery" 9 | "github.com/gin-gonic/gin" 10 | "github.com/sirupsen/logrus" 11 | ) 12 | 13 | func getConfig(c *gin.Context) { 14 | c.IndentedJSON(http.StatusOK, config) 15 | } 16 | 17 | func setConfig(c *gin.Context) { 18 | var cfg Config 19 | if err := c.BindJSON(&cfg); err != nil { 20 | c.IndentedJSON(http.StatusBadRequest, err.Error()) 21 | _ = c.AbortWithError(http.StatusBadRequest, err) 22 | return 23 | } 24 | 25 | if cfg.Limit < 10 || cfg.Limit > 100 { 26 | err := fmt.Errorf("limit must be between 10 and 100, got %d", cfg.Limit) 27 | c.IndentedJSON(http.StatusBadRequest, err.Error()) 28 | _ = c.AbortWithError(http.StatusBadRequest, err) 29 | return 30 | } 31 | 32 | config = cfg 33 | if err := saveConfig(); err != nil { 34 | logrus.Errorf("saveConfig failed: %v", err) 35 | c.IndentedJSON(http.StatusInternalServerError, err.Error()) 36 | _ = c.AbortWithError(http.StatusInternalServerError, err) 37 | return 38 | } 39 | 40 | logrus.Infof("set config: %#v", cfg) 41 | 42 | // Immediate single maintain loop, to avoid waiting for the next loop 43 | maintainLoopForced() 44 | c.IndentedJSON(http.StatusCreated, "ok") 45 | } 46 | 47 | func getLimit(c *gin.Context) { 48 | c.IndentedJSON(http.StatusOK, config.Limit) 49 | } 50 | 51 | func setLimit(c *gin.Context) { 52 | var l int 53 | if err := c.BindJSON(&l); err != nil { 54 | c.IndentedJSON(http.StatusBadRequest, err.Error()) 55 | _ = c.AbortWithError(http.StatusBadRequest, err) 56 | return 57 | } 58 | 59 | if l < 10 || l > 100 { 60 | err := fmt.Errorf("limit must be between 10 and 100, got %d", l) 61 | c.IndentedJSON(http.StatusBadRequest, err.Error()) 62 | _ = c.AbortWithError(http.StatusBadRequest, err) 63 | return 64 | } 65 | 66 | if l-config.LowerLimitDelta < 10 { 67 | err := fmt.Errorf("limit must be at least %d, got %d", 10+config.LowerLimitDelta, l) 68 | c.IndentedJSON(http.StatusBadRequest, err.Error()) 69 | _ = c.AbortWithError(http.StatusBadRequest, err) 70 | return 71 | } 72 | 73 | config.Limit = l 74 | if err := saveConfig(); err != nil { 75 | logrus.Errorf("saveConfig failed: %v", err) 76 | c.IndentedJSON(http.StatusInternalServerError, err.Error()) 77 | _ = c.AbortWithError(http.StatusInternalServerError, err) 78 | return 79 | } 80 | 81 | logrus.Infof("set charging limit to %d", l) 82 | 83 | var msg string 84 | charge, err := smcConn.GetBatteryCharge() 85 | if err != nil { 86 | msg = fmt.Sprintf("set upper/lower charging limit to %d%%/%d%%", l, l-config.LowerLimitDelta) 87 | } else { 88 | msg = fmt.Sprintf("set upper/lower charging limit to %d%%/%d%%, current charge: %d%%", l, l-config.LowerLimitDelta, charge) 89 | if charge > config.Limit { 90 | msg += ". Current charge is above the limit, so your computer will use power from the wall only. Battery charge will remain the same." 91 | } 92 | } 93 | 94 | if l >= 100 { 95 | msg = "set charging limit to 100%. batt will not control charging anymore." 96 | } 97 | 98 | // Immediate single maintain loop, to avoid waiting for the next loop 99 | maintainLoopForced() 100 | 101 | c.IndentedJSON(http.StatusCreated, msg) 102 | } 103 | 104 | func setPreventIdleSleep(c *gin.Context) { 105 | var p bool 106 | if err := c.BindJSON(&p); err != nil { 107 | c.IndentedJSON(http.StatusBadRequest, err.Error()) 108 | _ = c.AbortWithError(http.StatusBadRequest, err) 109 | return 110 | } 111 | 112 | config.PreventIdleSleep = p 113 | if err := saveConfig(); err != nil { 114 | logrus.Errorf("saveConfig failed: %v", err) 115 | c.IndentedJSON(http.StatusInternalServerError, err.Error()) 116 | _ = c.AbortWithError(http.StatusInternalServerError, err) 117 | return 118 | } 119 | 120 | logrus.Infof("set prevent idle sleep to %t", p) 121 | 122 | c.IndentedJSON(http.StatusCreated, "ok") 123 | } 124 | 125 | func setDisableChargingPreSleep(c *gin.Context) { 126 | var d bool 127 | if err := c.BindJSON(&d); err != nil { 128 | c.IndentedJSON(http.StatusBadRequest, err.Error()) 129 | _ = c.AbortWithError(http.StatusBadRequest, err) 130 | return 131 | } 132 | 133 | config.DisableChargingPreSleep = d 134 | if err := saveConfig(); err != nil { 135 | logrus.Errorf("saveConfig failed: %v", err) 136 | c.IndentedJSON(http.StatusInternalServerError, err.Error()) 137 | _ = c.AbortWithError(http.StatusInternalServerError, err) 138 | return 139 | } 140 | 141 | logrus.Infof("set disable charging pre sleep to %t", d) 142 | 143 | c.IndentedJSON(http.StatusCreated, "ok") 144 | } 145 | 146 | func setAdapter(c *gin.Context) { 147 | var d bool 148 | if err := c.BindJSON(&d); err != nil { 149 | c.IndentedJSON(http.StatusBadRequest, err.Error()) 150 | _ = c.AbortWithError(http.StatusBadRequest, err) 151 | return 152 | } 153 | 154 | if d { 155 | if err := smcConn.EnableAdapter(); err != nil { 156 | logrus.Errorf("enablePowerAdapter failed: %v", err) 157 | c.IndentedJSON(http.StatusInternalServerError, err.Error()) 158 | _ = c.AbortWithError(http.StatusInternalServerError, err) 159 | return 160 | } 161 | logrus.Infof("enabled power adapter") 162 | } else { 163 | if err := smcConn.DisableAdapter(); err != nil { 164 | logrus.Errorf("disablePowerAdapter failed: %v", err) 165 | c.IndentedJSON(http.StatusInternalServerError, err.Error()) 166 | _ = c.AbortWithError(http.StatusInternalServerError, err) 167 | return 168 | } 169 | logrus.Infof("disabled power adapter") 170 | } 171 | 172 | c.IndentedJSON(http.StatusCreated, "ok") 173 | } 174 | 175 | func getAdapter(c *gin.Context) { 176 | enabled, err := smcConn.IsAdapterEnabled() 177 | if err != nil { 178 | logrus.Errorf("getAdapter failed: %v", err) 179 | c.IndentedJSON(http.StatusInternalServerError, err.Error()) 180 | _ = c.AbortWithError(http.StatusInternalServerError, err) 181 | return 182 | } 183 | 184 | c.IndentedJSON(http.StatusOK, enabled) 185 | } 186 | 187 | func getCharging(c *gin.Context) { 188 | charging, err := smcConn.IsChargingEnabled() 189 | if err != nil { 190 | logrus.Errorf("getCharging failed: %v", err) 191 | c.IndentedJSON(http.StatusInternalServerError, err.Error()) 192 | _ = c.AbortWithError(http.StatusInternalServerError, err) 193 | return 194 | } 195 | 196 | c.IndentedJSON(http.StatusOK, charging) 197 | } 198 | 199 | func getBatteryInfo(c *gin.Context) { 200 | batteries, err := battery.GetAll() 201 | if err != nil { 202 | logrus.Errorf("getBatteryInfo failed: %v", err) 203 | c.IndentedJSON(http.StatusInternalServerError, err.Error()) 204 | _ = c.AbortWithError(http.StatusInternalServerError, err) 205 | return 206 | } 207 | 208 | if len(batteries) == 0 { 209 | logrus.Errorf("no batteries found") 210 | c.IndentedJSON(http.StatusInternalServerError, "no batteries found") 211 | _ = c.AbortWithError(http.StatusInternalServerError, errors.New("no batteries found")) 212 | return 213 | } 214 | 215 | bat := batteries[0] // All Apple Silicon MacBooks only have one battery. No need to support more. 216 | if bat.State == battery.Discharging { 217 | bat.ChargeRate = -bat.ChargeRate 218 | } 219 | 220 | c.IndentedJSON(http.StatusOK, bat) 221 | } 222 | 223 | func setLowerLimitDelta(c *gin.Context) { 224 | var d int 225 | if err := c.BindJSON(&d); err != nil { 226 | c.IndentedJSON(http.StatusBadRequest, err.Error()) 227 | _ = c.AbortWithError(http.StatusBadRequest, err) 228 | return 229 | } 230 | 231 | if d < 0 { 232 | err := fmt.Errorf("lower limit delta must be positive, got %d", d) 233 | c.IndentedJSON(http.StatusBadRequest, err.Error()) 234 | _ = c.AbortWithError(http.StatusBadRequest, err) 235 | return 236 | } 237 | 238 | if config.Limit-d < 10 { 239 | err := fmt.Errorf("lower limit delta must be less than limit - 10, got %d", d) 240 | c.IndentedJSON(http.StatusBadRequest, err.Error()) 241 | _ = c.AbortWithError(http.StatusBadRequest, err) 242 | return 243 | } 244 | 245 | config.LowerLimitDelta = d 246 | if err := saveConfig(); err != nil { 247 | logrus.Errorf("saveConfig failed: %v", err) 248 | c.IndentedJSON(http.StatusInternalServerError, err.Error()) 249 | _ = c.AbortWithError(http.StatusInternalServerError, err) 250 | return 251 | } 252 | 253 | ret := fmt.Sprintf("set lower limit delta to %d, current upper/lower limit is %d%%/%d%%", d, config.Limit, config.Limit-config.LowerLimitDelta) 254 | logrus.Info(ret) 255 | 256 | c.IndentedJSON(http.StatusCreated, ret) 257 | } 258 | 259 | func setControlMagSafeLED(c *gin.Context) { 260 | // Check if MasSafe is supported first. If not, return error. 261 | if !smcConn.CheckMagSafeExistence() { 262 | logrus.Errorf("setControlMagSafeLED called but there is no MasSafe LED on this device") 263 | err := fmt.Errorf("there is no MasSafe on this device. You can only enable this setting on a compatible device, e.g. MacBook Pro 14-inch 2021") 264 | c.IndentedJSON(http.StatusInternalServerError, err.Error()) 265 | _ = c.AbortWithError(http.StatusInternalServerError, err) 266 | return 267 | } 268 | 269 | var d bool 270 | if err := c.BindJSON(&d); err != nil { 271 | c.IndentedJSON(http.StatusBadRequest, err.Error()) 272 | _ = c.AbortWithError(http.StatusBadRequest, err) 273 | return 274 | } 275 | 276 | config.ControlMagSafeLED = d 277 | if err := saveConfig(); err != nil { 278 | logrus.Errorf("saveConfig failed: %v", err) 279 | c.IndentedJSON(http.StatusInternalServerError, err.Error()) 280 | _ = c.AbortWithError(http.StatusInternalServerError, err) 281 | return 282 | } 283 | 284 | logrus.Infof("set control MagSafe LED to %t", d) 285 | 286 | c.IndentedJSON(http.StatusCreated, fmt.Sprintf("ControlMagSafeLED set to %t. You should be able to see the effect in a few minutes.", d)) 287 | } 288 | 289 | func getCurrentCharge(c *gin.Context) { 290 | charge, err := smcConn.GetBatteryCharge() 291 | if err != nil { 292 | logrus.Errorf("getCurrentCharge failed: %v", err) 293 | c.IndentedJSON(http.StatusInternalServerError, err.Error()) 294 | _ = c.AbortWithError(http.StatusInternalServerError, err) 295 | return 296 | } 297 | 298 | c.IndentedJSON(http.StatusOK, charge) 299 | } 300 | 301 | func getPluggedIn(c *gin.Context) { 302 | pluggedIn, err := smcConn.IsPluggedIn() 303 | if err != nil { 304 | logrus.Errorf("getCurrentCharge failed: %v", err) 305 | c.IndentedJSON(http.StatusInternalServerError, err.Error()) 306 | _ = c.AbortWithError(http.StatusInternalServerError, err) 307 | return 308 | } 309 | 310 | c.IndentedJSON(http.StatusOK, pluggedIn) 311 | } 312 | -------------------------------------------------------------------------------- /hook.c: -------------------------------------------------------------------------------- 1 | // https://developer.apple.com/library/archive/qa/qa1340/_index.html 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | #include 8 | #include 9 | #include 10 | 11 | #include 12 | #include 13 | 14 | #include "hook.h" 15 | 16 | io_connect_t root_port; // a reference to the Root Power Domain IOService 17 | // notification port allocated by IORegisterForSystemPower 18 | IONotificationPortRef notifyPortRef; 19 | // notifier object, used to deregister later 20 | io_object_t notifierObject; 21 | long gMessageArgument; 22 | 23 | int AllowPowerChange() 24 | { 25 | return IOAllowPowerChange(root_port, gMessageArgument); 26 | } 27 | 28 | int CancelPowerChange() 29 | { 30 | return IOCancelPowerChange(root_port, gMessageArgument); 31 | } 32 | 33 | void sleepCallBack(void* refCon, io_service_t service, natural_t messageType, void* messageArgument) 34 | { 35 | gMessageArgument = (long)messageArgument; 36 | 37 | switch (messageType) { 38 | case kIOMessageCanSystemSleep: 39 | /* Idle sleep is about to kick in. This message will not be sent for forced sleep. 40 | Applications have a chance to prevent sleep by calling IOCancelPowerChange. 41 | Most applications should not prevent idle sleep. 42 | 43 | Power Management waits up to 30 seconds for you to either allow or deny idle 44 | sleep. If you don't acknowledge this power change by calling either 45 | IOAllowPowerChange or IOCancelPowerChange, the system will wait 30 46 | seconds then go to sleep. 47 | */ 48 | 49 | // Cancel idle sleep 50 | // IOCancelPowerChange( root_port, (long)messageArgument ); 51 | // Allow idle sleep 52 | // IOAllowPowerChange(root_port, (long)messageArgument); 53 | 54 | canSystemSleepCallback(); 55 | 56 | break; 57 | 58 | case kIOMessageSystemWillSleep: 59 | /* The system WILL go to sleep. If you do not call IOAllowPowerChange or 60 | IOCancelPowerChange to acknowledge this message, sleep will be 61 | delayed by 30 seconds. 62 | 63 | NOTE: If you call IOCancelPowerChange to deny sleep it returns 64 | kIOReturnSuccess, however the system WILL still go to sleep. 65 | */ 66 | 67 | systemWillSleepCallback(); 68 | 69 | break; 70 | 71 | case kIOMessageSystemWillPowerOn: 72 | // System has started the wake up process... 73 | 74 | systemWillPowerOnCallback(); 75 | 76 | break; 77 | 78 | case kIOMessageSystemHasPoweredOn: 79 | // System has finished waking up... 80 | 81 | systemHasPoweredOnCallback(); 82 | 83 | break; 84 | 85 | default: 86 | break; 87 | } 88 | } 89 | 90 | int ListenNotifications() 91 | { 92 | // this parameter is passed to the callback 93 | void* refCon; 94 | 95 | // register to receive system sleep notifications 96 | root_port = IORegisterForSystemPower(refCon, ¬ifyPortRef, sleepCallBack, ¬ifierObject); 97 | if (root_port == 0) { 98 | printf("IORegisterForSystemPower failed\n"); 99 | return 1; 100 | } 101 | 102 | // add the notification port to the application runloop 103 | CFRunLoopAddSource(CFRunLoopGetCurrent(), 104 | IONotificationPortGetRunLoopSource(notifyPortRef), kCFRunLoopCommonModes); 105 | 106 | // Start the run loop to receive sleep notifications. 107 | CFRunLoopRun(); 108 | 109 | // Not reached, CFRunLoopRun doesn't return in this case. 110 | return 0; 111 | } 112 | 113 | int StopListeningNotifications() 114 | { 115 | // remove the sleep notification port from the application runloop 116 | CFRunLoopRemoveSource(CFRunLoopGetCurrent(), 117 | IONotificationPortGetRunLoopSource(notifyPortRef), 118 | kCFRunLoopCommonModes); 119 | 120 | // deregister for system sleep notifications 121 | IODeregisterForSystemPower(¬ifierObject); 122 | 123 | // IORegisterForSystemPower implicitly opens the Root Power Domain IOService 124 | // so we close it here 125 | IOServiceClose(root_port); 126 | 127 | // destroy the notification port allocated by IORegisterForSystemPower 128 | IONotificationPortDestroy(notifyPortRef); 129 | 130 | return 0; 131 | } 132 | -------------------------------------------------------------------------------- /hook.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | extern void canSystemSleepCallback(); 4 | extern void systemWillSleepCallback(); 5 | extern void systemWillPowerOnCallback(); 6 | extern void systemHasPoweredOnCallback(); 7 | 8 | int AllowPowerChange(); 9 | int CancelPowerChange(); 10 | int ListenNotifications(); 11 | int StopListeningNotifications(); 12 | -------------------------------------------------------------------------------- /install.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/sirupsen/logrus" 12 | ) 13 | 14 | var ( 15 | //go:embed hack/cc.chlc.batt.plist 16 | plistTemplate string 17 | plistPath = "/Library/LaunchDaemons/cc.chlc.batt.plist" 18 | ) 19 | 20 | func installDaemon() error { 21 | // Get the path to the current executable 22 | exePath, err := os.Executable() 23 | if err != nil { 24 | return fmt.Errorf("failed to get the path to the current executable: %w", err) 25 | } 26 | exePath, err = filepath.Abs(exePath) 27 | if err != nil { 28 | return fmt.Errorf("failed to get the absolute path to the current executable: %w", err) 29 | } 30 | 31 | err = os.Chmod(exePath, 0755) 32 | if err != nil { 33 | return fmt.Errorf("failed to chmod the current executable to 0755: %w", err) 34 | } 35 | 36 | logrus.Infof("current executable path: %s", exePath) 37 | 38 | tmpl := strings.ReplaceAll(plistTemplate, "/path/to/batt", exePath) 39 | 40 | logrus.Infof("writing launch daemon to /Library/LaunchDaemons") 41 | 42 | // mkdir -p 43 | err = os.MkdirAll("/Library/LaunchDaemons", 0755) 44 | if err != nil { 45 | return fmt.Errorf("failed to create /Library/LaunchDaemons: %w", err) 46 | } 47 | 48 | // warn if the file already exists 49 | _, err = os.Stat(plistPath) 50 | if err == nil { 51 | logrus.Errorf("%s already exists", plistPath) 52 | return fmt.Errorf("%s already exists. This is often caused by an incorrect installation. Did you forget to uninstall batt before installing it again? Please uninstall it first, by running 'sudo batt uninstall'. If you already removed batt, you can solve this problem by 'sudo rm %s'", plistPath, plistPath) 53 | } 54 | 55 | err = os.WriteFile(plistPath, []byte(tmpl), 0644) 56 | if err != nil { 57 | return fmt.Errorf("failed to write %s: %w", plistPath, err) 58 | } 59 | 60 | // chown root:wheel 61 | err = os.Chown(plistPath, 0, 0) 62 | if err != nil { 63 | return fmt.Errorf("failed to chown %s: %w", plistPath, err) 64 | } 65 | 66 | logrus.Infof("starting batt") 67 | 68 | // run launchctl load /Library/LaunchDaemons/cc.chlc.batt.plist 69 | err = exec.Command( 70 | "/bin/launchctl", 71 | "load", 72 | plistPath, 73 | ).Run() 74 | if err != nil { 75 | return fmt.Errorf("failed to load %s: %w", plistPath, err) 76 | } 77 | 78 | return nil 79 | } 80 | 81 | func uninstallDaemon() error { 82 | logrus.Infof("stopping batt") 83 | 84 | // run launchctl unload /Library/LaunchDaemons/cc.chlc.batt.plist 85 | err := exec.Command( 86 | "/bin/launchctl", 87 | "unload", 88 | plistPath, 89 | ).Run() 90 | if err != nil { 91 | return fmt.Errorf("failed to unload %s: %w. Are you root?", plistPath, err) 92 | } 93 | 94 | logrus.Infof("removing launch daemon") 95 | 96 | // if the file doesn't exist, we don't need to remove it 97 | _, err = os.Stat(plistPath) 98 | if err != nil { 99 | if os.IsNotExist(err) { 100 | return nil 101 | } 102 | return fmt.Errorf("failed to stat %s: %w", plistPath, err) 103 | } 104 | 105 | err = os.Remove(plistPath) 106 | if err != nil { 107 | return fmt.Errorf("failed to remove %s: %w. Are you root?", plistPath, err) 108 | } 109 | 110 | return nil 111 | } 112 | -------------------------------------------------------------------------------- /loop.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "reflect" 5 | "sync" 6 | "time" 7 | 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | var ( 12 | maintainedChargingInProgress = false 13 | maintainLoopLock = &sync.Mutex{} 14 | // mg is used to skip several loops when system woke up or before sleep 15 | wg = &sync.WaitGroup{} 16 | loopInterval = time.Duration(10) * time.Second 17 | loopRecorder = NewTimeSeriesRecorder(60) 18 | continuousLoopThreshold = 1*time.Minute + 20*time.Second // add 20s to be sure 19 | ) 20 | 21 | // TimeSeriesRecorder records the last N maintain loop times. 22 | type TimeSeriesRecorder struct { 23 | MaxRecordCount int 24 | LastMaintainLoopTimes []time.Time 25 | mu *sync.Mutex 26 | } 27 | 28 | // NewTimeSeriesRecorder returns a new TimeSeriesRecorder. 29 | func NewTimeSeriesRecorder(maxRecordCount int) *TimeSeriesRecorder { 30 | return &TimeSeriesRecorder{ 31 | MaxRecordCount: maxRecordCount, 32 | LastMaintainLoopTimes: make([]time.Time, 0), 33 | mu: &sync.Mutex{}, 34 | } 35 | } 36 | 37 | // AddRecordNow adds a new record with the current time. 38 | func (r *TimeSeriesRecorder) AddRecordNow() { 39 | r.mu.Lock() 40 | defer r.mu.Unlock() 41 | 42 | if len(r.LastMaintainLoopTimes) >= r.MaxRecordCount { 43 | r.LastMaintainLoopTimes = r.LastMaintainLoopTimes[1:] 44 | } 45 | // Round to strip monotonic clock reading. 46 | // This will prevent time.Since from returning values that are not accurate (especially when the system is in sleep mode). 47 | r.LastMaintainLoopTimes = append(r.LastMaintainLoopTimes, time.Now().Round(0)) 48 | } 49 | 50 | // ClearRecords clears all records. 51 | func (r *TimeSeriesRecorder) ClearRecords() { 52 | r.mu.Lock() 53 | defer r.mu.Unlock() 54 | 55 | r.LastMaintainLoopTimes = make([]time.Time, 0) 56 | } 57 | 58 | // GetRecords returns the records. 59 | func (r *TimeSeriesRecorder) GetRecords() []time.Time { 60 | r.mu.Lock() 61 | defer r.mu.Unlock() 62 | 63 | return r.LastMaintainLoopTimes 64 | } 65 | 66 | // GetRecordsString returns the records in string format. 67 | func (r *TimeSeriesRecorder) GetRecordsString() []string { 68 | records := r.GetRecords() 69 | var recordsString []string 70 | for _, record := range records { 71 | recordsString = append(recordsString, record.Format(time.RFC3339)) 72 | } 73 | return recordsString 74 | } 75 | 76 | // AddRecord adds a new record. 77 | func (r *TimeSeriesRecorder) AddRecord(t time.Time) { 78 | r.mu.Lock() 79 | defer r.mu.Unlock() 80 | 81 | // Strip monotonic clock reading. 82 | t = t.Round(0) 83 | 84 | if len(r.LastMaintainLoopTimes) >= r.MaxRecordCount { 85 | r.LastMaintainLoopTimes = r.LastMaintainLoopTimes[1:] 86 | } 87 | r.LastMaintainLoopTimes = append(r.LastMaintainLoopTimes, t) 88 | } 89 | 90 | // GetRecordsIn returns the number of continuous records in the last duration. 91 | func (r *TimeSeriesRecorder) GetRecordsIn(last time.Duration) int { 92 | r.mu.Lock() 93 | defer r.mu.Unlock() 94 | 95 | // The last record must be within the last duration. 96 | if len(r.LastMaintainLoopTimes) > 0 && time.Since(r.LastMaintainLoopTimes[len(r.LastMaintainLoopTimes)-1]) >= loopInterval+time.Second { 97 | return 0 98 | } 99 | 100 | // Find continuous records from the end of the list. 101 | // Continuous records are defined as the time difference between 102 | // two adjacent records is less than loopInterval+1 second. 103 | count := 0 104 | for i := len(r.LastMaintainLoopTimes) - 1; i >= 0; i-- { 105 | record := r.LastMaintainLoopTimes[i] 106 | if time.Since(record) > last { 107 | break 108 | } 109 | 110 | theRecordAfter := record 111 | if i+1 < len(r.LastMaintainLoopTimes) { 112 | theRecordAfter = r.LastMaintainLoopTimes[i+1] 113 | } 114 | 115 | if theRecordAfter.Sub(record) >= loopInterval+time.Second { 116 | break 117 | } 118 | count++ 119 | } 120 | 121 | return count 122 | } 123 | 124 | // GetLastRecords returns the time differences between the records and the current time. 125 | func (r *TimeSeriesRecorder) GetLastRecords(last time.Duration) []time.Time { 126 | r.mu.Lock() 127 | defer r.mu.Unlock() 128 | 129 | if len(r.LastMaintainLoopTimes) == 0 { 130 | return nil 131 | } 132 | 133 | var records []time.Time 134 | for i := len(r.LastMaintainLoopTimes) - 1; i >= 0; i-- { 135 | record := r.LastMaintainLoopTimes[i] 136 | if time.Since(record) > last { 137 | break 138 | } 139 | records = append(records, record) 140 | } 141 | 142 | return records 143 | } 144 | 145 | //nolint:unused // . 146 | func formatTimes(times []time.Time) []string { 147 | var timesString []string 148 | for _, t := range times { 149 | timesString = append(timesString, t.Format(time.RFC3339)) 150 | } 151 | return timesString 152 | } 153 | 154 | func formatRelativeTimes(times []time.Time) []string { 155 | var timesString []string 156 | for _, t := range times { 157 | timesString = append(timesString, time.Since(t).String()) 158 | } 159 | return timesString 160 | } 161 | 162 | // GetLastRecord returns the last record. 163 | func (r *TimeSeriesRecorder) GetLastRecord() time.Time { 164 | r.mu.Lock() 165 | defer r.mu.Unlock() 166 | 167 | if len(r.LastMaintainLoopTimes) == 0 { 168 | return time.Time{} 169 | } 170 | 171 | return r.LastMaintainLoopTimes[len(r.LastMaintainLoopTimes)-1] 172 | } 173 | 174 | // infiniteLoop runs forever and maintains the battery charge, 175 | // which is called by the daemon. 176 | func infiniteLoop() { 177 | for { 178 | maintainLoop() 179 | time.Sleep(loopInterval) 180 | } 181 | } 182 | 183 | // maintainLoop maintains the battery charge. It has the logic to 184 | // prevent parallel runs. So if one maintain loop is already running, 185 | // the next one will need to wait until the first one finishes. 186 | func maintainLoop() bool { 187 | maintainLoopLock.Lock() 188 | defer maintainLoopLock.Unlock() 189 | 190 | // See wg.Add() in sleepcallback.go for why we need to wait. 191 | tsBeforeWait := time.Now() 192 | wg.Wait() 193 | tsAfterWait := time.Now() 194 | if tsAfterWait.Sub(tsBeforeWait) > time.Second*1 { 195 | logrus.Debugf("this maintain loop waited %d seconds after being initiated, now ready to execute", int(tsAfterWait.Sub(tsBeforeWait).Seconds())) 196 | } 197 | 198 | // TODO: put it in a function (and the similar code in maintainLoopForced) 199 | maintainLoopCount := loopRecorder.GetRecordsIn(continuousLoopThreshold) 200 | expectedMaintainLoopCount := int(continuousLoopThreshold / loopInterval) 201 | minMaintainLoopCount := expectedMaintainLoopCount - 1 202 | relativeTimes := loopRecorder.GetLastRecords(continuousLoopThreshold) 203 | // If maintain loop is missed too many times, we assume the system is in a rapid sleep/wake loop, or macOS 204 | // haven't sent the sleep notification but the system is actually sleep/waking up. In either case, log it. 205 | if maintainLoopCount < minMaintainLoopCount { 206 | logrus.WithFields(logrus.Fields{ 207 | "maintainLoopCount": maintainLoopCount, 208 | "expectedMaintainLoopCount": expectedMaintainLoopCount, 209 | "minMaintainLoopCount": minMaintainLoopCount, 210 | "recentRecords": formatRelativeTimes(relativeTimes), 211 | }).Infof("Possibly missed maintain loop") 212 | } 213 | 214 | loopRecorder.AddRecordNow() 215 | return maintainLoopInner(false) 216 | } 217 | 218 | // maintainLoopForced maintains the battery charge. It runs as soon as 219 | // it is called, without waiting for the previous maintain loop to finish. 220 | // It is mainly called by the HTTP APIs. 221 | func maintainLoopForced() bool { 222 | return maintainLoopInner(true) 223 | } 224 | 225 | func maintainLoopInner(ignoreMissedLoops bool) bool { 226 | upper := config.Limit 227 | delta := config.LowerLimitDelta 228 | lower := upper - delta 229 | maintain := upper < 100 230 | 231 | isChargingEnabled, err := smcConn.IsChargingEnabled() 232 | if err != nil { 233 | logrus.Errorf("IsChargingEnabled failed: %v", err) 234 | return false 235 | } 236 | 237 | // If maintain is disabled, we don't care about the battery charge, enable charging anyway. 238 | if !maintain { 239 | logrus.Debug("limit set to 100%, maintain loop disabled") 240 | if !isChargingEnabled { 241 | logrus.Debug("charging disabled, enabling") 242 | err = smcConn.EnableCharging() 243 | if err != nil { 244 | logrus.Errorf("EnableCharging failed: %v", err) 245 | return false 246 | } 247 | if config.ControlMagSafeLED { 248 | batteryCharge, err := smcConn.GetBatteryCharge() 249 | if err == nil { 250 | _ = smcConn.SetMagSafeCharging(batteryCharge < 100) 251 | } 252 | } 253 | } 254 | maintainedChargingInProgress = false 255 | return true 256 | } 257 | 258 | batteryCharge, err := smcConn.GetBatteryCharge() 259 | if err != nil { 260 | logrus.Errorf("GetBatteryCharge failed: %v", err) 261 | return false 262 | } 263 | 264 | isPluggedIn, err := smcConn.IsPluggedIn() 265 | if err != nil { 266 | logrus.Errorf("IsPluggedIn failed: %v", err) 267 | return false 268 | } 269 | 270 | maintainedChargingInProgress = isChargingEnabled && isPluggedIn 271 | 272 | printStatus(batteryCharge, lower, upper, isChargingEnabled, isPluggedIn, maintainedChargingInProgress) 273 | 274 | if batteryCharge < lower && !isChargingEnabled { 275 | if !ignoreMissedLoops { 276 | maintainLoopCount := loopRecorder.GetRecordsIn(continuousLoopThreshold) 277 | expectedMaintainLoopCount := int(continuousLoopThreshold / loopInterval) 278 | minMaintainLoopCount := expectedMaintainLoopCount - 1 279 | relativeTimes := loopRecorder.GetLastRecords(continuousLoopThreshold) 280 | // If maintain loop is missed too many times, we assume the system is in a rapid sleep/wake loop, or macOS 281 | // haven't sent the sleep notification but the system is actually sleep/waking up. In either case, we should 282 | // not enable charging, which will cause unexpected charging. 283 | // 284 | // This is a workaround for the issue that macOS sometimes doesn't send the sleep notification. 285 | // 286 | // We allow at most 1 missed maintain loop. 287 | if maintainLoopCount < minMaintainLoopCount { 288 | logrus.WithFields(logrus.Fields{ 289 | "batteryCharge": batteryCharge, 290 | "lower": lower, 291 | "upper": upper, 292 | "delta": delta, 293 | "maintainLoopCount": maintainLoopCount, 294 | "expectedMaintainLoopCount": expectedMaintainLoopCount, 295 | "minMaintainLoopCount": minMaintainLoopCount, 296 | "recentRecords": formatRelativeTimes(relativeTimes), 297 | }).Infof("Battery charge is below lower limit, but too many missed maintain loops are missed. Will wait until maintain loops are stable") 298 | return true 299 | } 300 | } 301 | 302 | logrus.WithFields(logrus.Fields{ 303 | "batteryCharge": batteryCharge, 304 | "lower": lower, 305 | "upper": upper, 306 | "delta": delta, 307 | }).Infof("Battery charge is below lower limit, enabling charging") 308 | err = smcConn.EnableCharging() 309 | if err != nil { 310 | logrus.Errorf("EnableCharging failed: %v", err) 311 | return false 312 | } 313 | isChargingEnabled = true 314 | maintainedChargingInProgress = true 315 | } 316 | 317 | if batteryCharge >= upper && isChargingEnabled { 318 | logrus.WithFields(logrus.Fields{ 319 | "batteryCharge": batteryCharge, 320 | "lower": lower, 321 | "upper": upper, 322 | "delta": delta, 323 | }).Infof("Battery charge is above upper limit, disabling charging") 324 | err = smcConn.DisableCharging() 325 | if err != nil { 326 | logrus.Errorf("DisableCharging failed: %v", err) 327 | return false 328 | } 329 | isChargingEnabled = false 330 | maintainedChargingInProgress = false 331 | } 332 | 333 | if config.ControlMagSafeLED { 334 | updateMagSafeLed(isChargingEnabled) 335 | } 336 | 337 | // batteryCharge >= upper - delta && batteryCharge < upper 338 | // do nothing, keep as-is 339 | 340 | return true 341 | } 342 | 343 | func updateMagSafeLed(isChargingEnabled bool) { 344 | err := smcConn.SetMagSafeCharging(isChargingEnabled) 345 | if err != nil { 346 | logrus.Errorf("SetMagSafeCharging failed: %v", err) 347 | } 348 | } 349 | 350 | var lastPrintTime time.Time 351 | 352 | type loopStatus struct { 353 | batteryCharge int 354 | lower int 355 | upper int 356 | isChargingEnabled bool 357 | isPluggedIn bool 358 | maintainedChargingInProgress bool 359 | } 360 | 361 | var lastStatus loopStatus 362 | 363 | func printStatus( 364 | batteryCharge int, 365 | lower int, 366 | upper int, 367 | isChargingEnabled bool, 368 | isPluggedIn bool, 369 | maintainedChargingInProgress bool, 370 | ) { 371 | currentStatus := loopStatus{ 372 | batteryCharge: batteryCharge, 373 | lower: lower, 374 | upper: upper, 375 | isChargingEnabled: isChargingEnabled, 376 | isPluggedIn: isPluggedIn, 377 | maintainedChargingInProgress: maintainedChargingInProgress, 378 | } 379 | 380 | fields := logrus.Fields{ 381 | "batteryCharge": batteryCharge, 382 | "lower": lower, 383 | "upper": upper, 384 | "chargingEnabled": isChargingEnabled, 385 | "isPluggedIn": isPluggedIn, 386 | "maintainedChargingInProgress": maintainedChargingInProgress, 387 | } 388 | 389 | defer func() { lastPrintTime = time.Now() }() 390 | 391 | // Skip printing if the last print was less than loopInterval+1 seconds ago and everything is the same. 392 | if time.Since(lastPrintTime) < loopInterval+time.Second && reflect.DeepEqual(lastStatus, currentStatus) { 393 | logrus.WithFields(fields).Trace("maintain loop status") 394 | return 395 | } 396 | 397 | logrus.WithFields(fields).Debug("maintain loop status") 398 | 399 | lastStatus = currentStatus 400 | } 401 | -------------------------------------------------------------------------------- /loop_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "sync" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestMaintainLoopRecorder_GetRecordsIn(t *testing.T) { 10 | type fields struct { 11 | MaxRecordCount int 12 | LastMaintainLoopTimes []time.Time 13 | mu *sync.Mutex 14 | } 15 | type args struct { 16 | last time.Duration 17 | } 18 | tests := []struct { 19 | name string 20 | fields fields 21 | args args 22 | want int 23 | }{ 24 | { 25 | name: "test noncontinuous records", 26 | fields: fields{ 27 | MaxRecordCount: 10, 28 | LastMaintainLoopTimes: []time.Time{ 29 | time.Now().Add(-time.Second * 31).Add(-10 * time.Millisecond), 30 | time.Now().Add(-time.Second * 20).Add(-10 * time.Millisecond), 31 | time.Now().Add(-time.Second * 10).Add(-10 * time.Millisecond), 32 | }, 33 | mu: &sync.Mutex{}, 34 | }, 35 | args: args{ 36 | last: time.Second * 40, 37 | }, 38 | want: 2, 39 | }, 40 | { 41 | name: "test continuous records", 42 | fields: fields{ 43 | MaxRecordCount: 10, 44 | LastMaintainLoopTimes: []time.Time{ 45 | time.Now().Add(-time.Second * 70).Add(-10 * time.Millisecond), 46 | time.Now().Add(-time.Second * 60).Add(-10 * time.Millisecond), 47 | time.Now().Add(-time.Second * 40).Add(-10 * time.Millisecond), 48 | time.Now().Add(-time.Second * 30).Add(-10 * time.Millisecond), 49 | time.Now().Add(-time.Second * 20).Add(-10 * time.Millisecond), 50 | time.Now().Add(-time.Second * 10).Add(-10 * time.Millisecond), 51 | }, 52 | mu: &sync.Mutex{}, 53 | }, 54 | args: args{ 55 | last: time.Second * 50, 56 | }, 57 | want: 4, 58 | }, 59 | { 60 | name: "test continuous records 2", 61 | fields: fields{ 62 | MaxRecordCount: 10, 63 | LastMaintainLoopTimes: []time.Time{ 64 | time.Now().Add(-time.Second * 70).Add(-10 * time.Millisecond), 65 | time.Now().Add(-time.Second * 60).Add(-10 * time.Millisecond), 66 | time.Now().Add(-time.Second * 40).Add(-10 * time.Millisecond), 67 | time.Now().Add(-time.Second * 30).Add(-10 * time.Millisecond), 68 | time.Now().Add(-time.Second * 20).Add(-10 * time.Millisecond), 69 | time.Now().Add(-time.Second * 15).Add(-10 * time.Millisecond), 70 | }, 71 | mu: &sync.Mutex{}, 72 | }, 73 | args: args{ 74 | last: time.Second * 50, 75 | }, 76 | want: 0, 77 | }, 78 | } 79 | for _, tt := range tests { 80 | loopInterval = time.Second * 10 81 | t.Run(tt.name, func(t *testing.T) { 82 | r := &TimeSeriesRecorder{ 83 | MaxRecordCount: tt.fields.MaxRecordCount, 84 | LastMaintainLoopTimes: tt.fields.LastMaintainLoopTimes, 85 | mu: tt.fields.mu, 86 | } 87 | if got := r.GetRecordsIn(tt.args.last); got != tt.want { 88 | t.Errorf("GetRecordsIn() = %v, want %v", got, tt.want) 89 | } 90 | }) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/sirupsen/logrus" 8 | ) 9 | 10 | func setupLogger() error { 11 | level, err := logrus.ParseLevel(logLevel) 12 | if err != nil { 13 | return fmt.Errorf("failed to parse log level: %v", err) 14 | } 15 | logrus.SetLevel(level) 16 | logrus.SetFormatter(&logrus.TextFormatter{ 17 | FullTimestamp: true, 18 | }) 19 | 20 | return nil 21 | } 22 | 23 | func main() { 24 | cmd := NewCommand() 25 | if err := cmd.Execute(); err != nil { 26 | os.Exit(1) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /makefiles/common.mk: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Charlie Chiang 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # Set this to 1 to enable debugging output. 16 | DBG_MAKEFILE ?= 17 | ifeq ($(DBG_MAKEFILE),1) 18 | $(warning ***** starting Makefile for goal(s) "$(MAKECMDGOALS)") 19 | $(warning ***** $(shell date)) 20 | else 21 | # If we're not debugging the Makefile, don't echo recipes. 22 | MAKEFLAGS += -s 23 | endif 24 | 25 | # No, we don't want builtin rules. 26 | MAKEFLAGS += --no-builtin-rules 27 | # Get some warnings about undefined variables 28 | MAKEFLAGS += --warn-undefined-variables 29 | # Get rid of .PHONY everywhere. 30 | MAKEFLAGS += --always-make 31 | 32 | # Use bash explicitly 33 | SHELL := /usr/bin/env bash -o errexit -o pipefail -o nounset 34 | -------------------------------------------------------------------------------- /makefiles/consts.mk: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Charlie Chiang 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # Those variables assigned with ?= can be overridden by setting them 16 | # manually on the command line or using environment variables. 17 | 18 | # Go version used as the image of the build container, grabbed from go.mod 19 | GO_VERSION := $(shell grep -E '^go [[:digit:]]{1,3}\.[[:digit:]]{1,3}$$' go.mod | sed 's/go //') 20 | # Local Go release version (only supports go1.16 and later) 21 | LOCAL_GO_VERSION := $(shell go env GOVERSION 2>/dev/null | grep -oE "go[[:digit:]]{1,3}\.[[:digit:]]{1,3}" || echo "none") 22 | 23 | # Warn if local go release version is different from what is specified in go.mod. 24 | ifneq (none, $(LOCAL_GO_VERSION)) 25 | ifneq (go$(GO_VERSION), $(LOCAL_GO_VERSION)) 26 | $(warning Your local Go release ($(LOCAL_GO_VERSION)) is different from the one that this go module assumes (go$(GO_VERSION)).) 27 | endif 28 | endif 29 | 30 | # Set DEBUG to 1 to optimize binary for debugging, otherwise for release 31 | DEBUG ?= 32 | 33 | # Version string, use git tag by default 34 | VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "UNKNOWN") 35 | GIT_COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null || echo "UNKNOWN") 36 | 37 | GOOS ?= 38 | GOARCH ?= 39 | # If user has not defined GOOS/GOARCH, use Go defaults. 40 | # If user don't have Go, use the os/arch of their machine. 41 | ifeq (, $(shell which go)) 42 | HOSTOS := $(shell uname -s | tr '[:upper:]' '[:lower:]') 43 | HOSTARCH := $(shell uname -m) 44 | ifeq ($(HOSTARCH),x86_64) 45 | HOSTARCH := amd64 46 | endif 47 | OS := $(if $(GOOS),$(GOOS),$(HOSTOS)) 48 | ARCH := $(if $(GOARCH),$(GOARCH),$(HOSTARCH)) 49 | else 50 | OS := $(if $(GOOS),$(GOOS),$(shell go env GOOS)) 51 | ARCH := $(if $(GOARCH),$(GOARCH),$(shell go env GOARCH)) 52 | endif 53 | 54 | # Binary name 55 | BIN_BASENAME := $(BIN) 56 | # Binary name with extended info, i.e. version-os-arch 57 | BIN_FULLNAME := $(BIN)-$(VERSION)-$(OS)-$(ARCH) 58 | # Package filename (generated by `make package'). Use zip for Windows, tar.gz for all other platforms. 59 | PKG_FULLNAME := $(BIN_FULLNAME).tar.gz 60 | # Checksum filename 61 | CHECKSUM_FULLNAME := $(BIN)-$(VERSION)-checksums.txt 62 | 63 | # This holds build output and helper tools 64 | DIST := bin 65 | # Full output directory 66 | BIN_OUTPUT_DIR := $(DIST)/$(BIN)-$(VERSION) 67 | PKG_OUTPUT_DIR := $(BIN_OUTPUT_DIR)/packages 68 | # Full output path with filename 69 | OUTPUT := $(BIN_OUTPUT_DIR)/$(BIN_FULLNAME) 70 | PKG_OUTPUT := $(PKG_OUTPUT_DIR)/$(PKG_FULLNAME) 71 | -------------------------------------------------------------------------------- /makefiles/targets.mk: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Charlie Chiang 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | all: build 16 | 17 | # ===== BUILD ===== 18 | 19 | build-dirs: 20 | mkdir -p "$(BIN_OUTPUT_DIR)" 21 | 22 | build: # @HELP (default) build binary for current platform 23 | build: build-dirs 24 | echo "# BUILD using local go sdk: $(LOCAL_GO_VERSION)" 25 | ARCH="$(ARCH)" \ 26 | OS="$(OS)" \ 27 | OUTPUT="$(OUTPUT)" \ 28 | VERSION="$(VERSION)" \ 29 | GIT_COMMIT="$(GIT_COMMIT)" \ 30 | DEBUG="$(DEBUG)" \ 31 | bash build/build.sh $(ENTRY) 32 | echo "# BUILD linking $(DIST)/$(BIN_BASENAME) <==> $(OUTPUT) ..." 33 | ln -f "$(OUTPUT)" "$(DIST)/$(BIN_BASENAME)" 34 | 35 | # INTERNAL: build-_ to build for a specific platform 36 | build-%: 37 | $(MAKE) -f $(firstword $(MAKEFILE_LIST)) \ 38 | build \ 39 | --no-print-directory \ 40 | GOOS=$(firstword $(subst _, ,$*)) \ 41 | GOARCH=$(lastword $(subst _, ,$*)) 42 | 43 | all-build: # @HELP build binaries for all platforms 44 | all-build: $(addprefix build-, $(subst /,_, $(BIN_PLATFORMS))) 45 | 46 | # ===== PACKAGE ===== 47 | 48 | package: # @HELP build and package binary for current platform 49 | package: build 50 | mkdir -p "$(PKG_OUTPUT_DIR)" 51 | ln -f LICENSE "$(DIST)/LICENSE" 52 | echo "# PACKAGE compressing $(OUTPUT) to $(PKG_OUTPUT)" 53 | $(RM) "$(PKG_OUTPUT)" 54 | tar czf "$(PKG_OUTPUT)" -C "$(DIST)" "$(BIN_BASENAME)" LICENSE; 55 | cd "$(PKG_OUTPUT_DIR)" && sha256sum "$(PKG_FULLNAME)" >> "$(CHECKSUM_FULLNAME)"; 56 | echo "# PACKAGE checksum saved to $(PKG_OUTPUT_DIR)/$(CHECKSUM_FULLNAME)" 57 | echo "# PACKAGE linking $(DIST)/$(BIN)-packages-latest <==> $(PKG_OUTPUT_DIR)" 58 | ln -snf "$(BIN)-$(VERSION)/packages" "$(DIST)/$(BIN)-packages-latest" 59 | 60 | # INTERNAL: package-_ to build and package for a specific platform 61 | package-%: 62 | $(MAKE) -f $(firstword $(MAKEFILE_LIST)) \ 63 | package \ 64 | --no-print-directory \ 65 | GOOS=$(firstword $(subst _, ,$*)) \ 66 | GOARCH=$(lastword $(subst _, ,$*)) 67 | 68 | all-package: # @HELP build and package binaries for all platforms 69 | all-package: $(addprefix package-, $(subst /,_, $(BIN_PLATFORMS))) 70 | # overwrite previous checksums 71 | cd "$(PKG_OUTPUT_DIR)" && shopt -s nullglob && \ 72 | sha256sum *.{tar.gz,zip} > "$(CHECKSUM_FULLNAME)" 73 | echo "# PACKAGE all checksums saved to $(PKG_OUTPUT_DIR)/$(CHECKSUM_FULLNAME)" 74 | 75 | # ===== MISC ===== 76 | 77 | clean: # @HELP clean built binaries 78 | clean: 79 | $(RM) -r $(DIST)/$(BIN)* 80 | 81 | all-clean: # @HELP clean built binaries, build cache, and helper tools 82 | all-clean: clean 83 | test -d $(GOCACHE) && chmod -R u+w $(GOCACHE) || true 84 | $(RM) -r $(GOCACHE) $(DIST) 85 | 86 | version: # @HELP output the version string 87 | version: 88 | echo $(VERSION) 89 | 90 | binaryname: # @HELP output current artifact binary name 91 | binaryname: 92 | echo $(BIN_FULLNAME) 93 | 94 | variables: # @HELP print makefile variables 95 | variables: 96 | echo "BUILD:" 97 | echo " build_output $(OUTPUT)" 98 | echo " app_version $(VERSION)" 99 | echo " git_commit $(GIT_COMMIT)" 100 | echo " debug_build_enabled $(DEBUG)" 101 | echo " local_go_sdk $(LOCAL_GO_VERSION)" 102 | echo "PLATFORM:" 103 | echo " current_os $(OS)" 104 | echo " current_arch $(ARCH)" 105 | echo " all_bin_os_arch $(BIN_PLATFORMS)" 106 | 107 | help: # @HELP print this message 108 | help: variables 109 | echo "MAKE_TARGETS:" 110 | grep -E '^.*: *# *@HELP' $(MAKEFILE_LIST) \ 111 | | sed -E 's_.*.mk:__g' \ 112 | | awk ' \ 113 | BEGIN {FS = ": *# *@HELP"}; \ 114 | { printf " %-23s %s\n", $$1, $$2 }; \ 115 | ' 116 | -------------------------------------------------------------------------------- /nonbrew.go: -------------------------------------------------------------------------------- 1 | //go:build !brew 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | 9 | "github.com/charlie0129/batt/pkg/smc" 10 | "github.com/sirupsen/logrus" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | func init() { 15 | commandGroups = append(commandGroups, gInstallation) 16 | } 17 | 18 | // NewInstallCommand . 19 | func NewInstallCommand() *cobra.Command { 20 | cmd := &cobra.Command{ 21 | Use: "install", 22 | Short: "Install batt (system-wide)", 23 | GroupID: gInstallation, 24 | Long: `Install batt daemon to launchd (system-wide). 25 | 26 | This makes batt run in the background and automatically start on boot. You must run this command as root. 27 | 28 | By default, only root user is allowed to access the batt daemon for security reasons. As a result, you will need to run batt client as root to control battery charging, e.g. setting charge limit. If you want to allow non-root users, i.e., you, to access the daemon, you can use the --allow-non-root-access flag, so you don't have to use sudo every time.`, 29 | PreRunE: func(cmd *cobra.Command, _ []string) error { 30 | 31 | err := loadConfig() 32 | if err != nil { 33 | return fmt.Errorf("failed to parse config during installation: %v", err) 34 | } 35 | 36 | flags := cmd.Flags() 37 | b, err := flags.GetBool("allow-non-root-access") 38 | if err != nil { 39 | return err 40 | } 41 | 42 | if config.AllowNonRootAccess && !b { 43 | logrus.Warnf("Previously, non-root users were allowed to access the batt daemon. However, this will be disabled at every installation unless you provide the --allow-non-root-access flag. Consider using the flag if you want to allow non-root users to access the daemon.") 44 | } 45 | 46 | // Before installation, always reset config.AllowNonRootAccess to flag value 47 | // instead of the one in config file. 48 | config.AllowNonRootAccess = b 49 | 50 | return nil 51 | }, 52 | RunE: func(cmd *cobra.Command, _ []string) error { 53 | if config.AllowNonRootAccess { 54 | logrus.Info("non-root users are allowed to access the batt daemon.") 55 | } else { 56 | logrus.Info("only root user is allowed to access the batt daemon.") 57 | } 58 | 59 | err := installDaemon() 60 | if err != nil { 61 | // check if current user is root 62 | if os.Geteuid() != 0 { 63 | logrus.Errorf("you must run this command as root") 64 | } 65 | return fmt.Errorf("failed to install daemon: %v. Are you root?", err) 66 | } 67 | 68 | err = saveConfig() 69 | if err != nil { 70 | return err 71 | } 72 | 73 | logrus.Infof("installation succeeded") 74 | 75 | exePath, _ := os.Executable() 76 | 77 | cmd.Printf("`launchd' will use current binary (%s) at startup so please make sure you do not move this binary. Once this binary is moved or deleted, you will need to run ``batt install'' again.\n", exePath) 78 | 79 | return nil 80 | }, 81 | } 82 | 83 | cmd.Flags().Bool("allow-non-root-access", false, "Allow non-root users to access batt daemon.") 84 | 85 | return cmd 86 | } 87 | 88 | // NewUninstallCommand . 89 | func NewUninstallCommand() *cobra.Command { 90 | return &cobra.Command{ 91 | Use: "uninstall", 92 | Short: "Uninstall batt (system-wide)", 93 | GroupID: gInstallation, 94 | Long: `Uninstall batt daemon from launchd (system-wide). 95 | 96 | This stops batt and removes it from launchd. 97 | 98 | You must run this command as root.`, 99 | RunE: func(cmd *cobra.Command, _ []string) error { 100 | err := uninstallDaemon() 101 | if err != nil { 102 | // check if current user is root 103 | if os.Geteuid() != 0 { 104 | logrus.Errorf("you must run this command as root") 105 | } 106 | return fmt.Errorf("failed to uninstall daemon: %v", err) 107 | } 108 | 109 | logrus.Infof("resetting charge limits") 110 | 111 | // Open Apple SMC for read/writing 112 | smcC := smc.New() 113 | if err := smcC.Open(); err != nil { 114 | return fmt.Errorf("failed to open SMC: %v", err) 115 | } 116 | 117 | err = smcC.EnableCharging() 118 | if err != nil { 119 | return fmt.Errorf("failed to enable charging: %v", err) 120 | } 121 | 122 | err = smcC.EnableAdapter() 123 | if err != nil { 124 | return fmt.Errorf("failed to enable adapter: %v", err) 125 | } 126 | 127 | if err := smcC.Close(); err != nil { 128 | return fmt.Errorf("failed to close SMC: %v", err) 129 | } 130 | 131 | fmt.Println("successfully uninstalled") 132 | 133 | cmd.Printf("Your config is kept in %s, in case you want to use `batt' again. If you want a complete uninstall, you can remove both config file and batt itself manually.\n", configPath) 134 | 135 | return nil 136 | }, 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /pkg/smc/acpower.go: -------------------------------------------------------------------------------- 1 | package smc 2 | 3 | import "github.com/sirupsen/logrus" 4 | 5 | // IsPluggedIn returns whether the device is plugged in. 6 | func (c *AppleSMC) IsPluggedIn() (bool, error) { 7 | logrus.Tracef("IsPluggedIn called") 8 | 9 | v, err := c.Read(ACPowerKey) 10 | if err != nil { 11 | return false, err 12 | } 13 | 14 | ret := len(v.Bytes) == 1 && int8(v.Bytes[0]) > 0 15 | logrus.Tracef("IsPluggedIn returned %t", ret) 16 | 17 | return ret, nil 18 | } 19 | -------------------------------------------------------------------------------- /pkg/smc/adapter.go: -------------------------------------------------------------------------------- 1 | package smc 2 | 3 | import "github.com/sirupsen/logrus" 4 | 5 | // IsAdapterEnabled returns whether the adapter is enabled. 6 | func (c *AppleSMC) IsAdapterEnabled() (bool, error) { 7 | logrus.Tracef("IsAdapterEnabled called") 8 | 9 | v, err := c.Read(AdapterKey) 10 | if err != nil { 11 | return false, err 12 | } 13 | 14 | ret := len(v.Bytes) == 1 && v.Bytes[0] == 0x0 15 | logrus.Tracef("IsAdapterEnabled returned %t", ret) 16 | 17 | return ret, nil 18 | } 19 | 20 | // EnableAdapter enables the adapter. 21 | func (c *AppleSMC) EnableAdapter() error { 22 | logrus.Tracef("EnableAdapter called") 23 | 24 | return c.Write(AdapterKey, []byte{0x0}) 25 | } 26 | 27 | // DisableAdapter disables the adapter. 28 | func (c *AppleSMC) DisableAdapter() error { 29 | logrus.Tracef("DisableAdapter called") 30 | 31 | return c.Write(AdapterKey, []byte{0x1}) 32 | } 33 | -------------------------------------------------------------------------------- /pkg/smc/battery.go: -------------------------------------------------------------------------------- 1 | package smc 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/sirupsen/logrus" 7 | ) 8 | 9 | // GetBatteryCharge returns the battery charge. 10 | func (c *AppleSMC) GetBatteryCharge() (int, error) { 11 | logrus.Tracef("GetBatteryCharge called") 12 | 13 | v, err := c.Read(BatteryChargeKey) 14 | if err != nil { 15 | return 0, err 16 | } 17 | 18 | if len(v.Bytes) != 1 { 19 | return 0, fmt.Errorf("incorrect data length %d!=1", len(v.Bytes)) 20 | } 21 | 22 | return int(v.Bytes[0]), nil 23 | } 24 | -------------------------------------------------------------------------------- /pkg/smc/charging.go: -------------------------------------------------------------------------------- 1 | package smc 2 | 3 | import "github.com/sirupsen/logrus" 4 | 5 | // IsChargingEnabled returns whether charging is enabled. 6 | func (c *AppleSMC) IsChargingEnabled() (bool, error) { 7 | logrus.Tracef("IsChargingEnabled called") 8 | 9 | v, err := c.Read(ChargingKey1) 10 | if err != nil { 11 | return false, err 12 | } 13 | 14 | ret := len(v.Bytes) == 1 && v.Bytes[0] == 0x0 15 | logrus.Tracef("IsChargingEnabled returned %t", ret) 16 | 17 | return ret, nil 18 | } 19 | 20 | // EnableCharging enables charging. 21 | func (c *AppleSMC) EnableCharging() error { 22 | logrus.Tracef("EnableCharging called") 23 | 24 | // CHSC 25 | err := c.Write(ChargingKey1, []byte{0x0}) 26 | if err != nil { 27 | return err 28 | } 29 | 30 | err = c.Write(ChargingKey2, []byte{0x0}) 31 | if err != nil { 32 | return err 33 | } 34 | 35 | return c.EnableAdapter() 36 | } 37 | 38 | // DisableCharging disables charging. 39 | func (c *AppleSMC) DisableCharging() error { 40 | logrus.Tracef("DisableCharging called") 41 | 42 | err := c.Write(ChargingKey1, []byte{0x2}) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | return c.Write(ChargingKey2, []byte{0x2}) 48 | } 49 | -------------------------------------------------------------------------------- /pkg/smc/consts_amd64.go: -------------------------------------------------------------------------------- 1 | package smc 2 | 3 | // Various SMC keys for amd64 (Intel 64). 4 | // This file is not used currently because we have no 5 | // plan to support Classic Intel MacBooks. However, 6 | // if we want to, we have something to work on. 7 | const ( 8 | MagSafeLedKey = "ACLC" // Not verified yet. 9 | ACPowerKey = "AC-W" // Not verified yet. 10 | ChargingKey1 = "CH0B" // Not verified yet. 11 | ChargingKey2 = "CH0C" // Not verified yet. 12 | AdapterKey = "CH0K" 13 | BatteryChargeKey = "BBIF" 14 | ) 15 | -------------------------------------------------------------------------------- /pkg/smc/consts_arm64.go: -------------------------------------------------------------------------------- 1 | package smc 2 | 3 | // Various SMC keys for arm64 (Apple Silicon) 4 | const ( 5 | MagSafeLedKey = "ACLC" 6 | ACPowerKey = "AC-W" 7 | ChargingKey1 = "CH0B" 8 | ChargingKey2 = "CH0C" 9 | AdapterKey = "CH0I" // CH0K on Intel, if we need it later 10 | BatteryChargeKey = "BUIC" 11 | ) 12 | -------------------------------------------------------------------------------- /pkg/smc/magsafe.go: -------------------------------------------------------------------------------- 1 | package smc 2 | 3 | import "github.com/sirupsen/logrus" 4 | 5 | // MagSafeLedState is the state of the MagSafe LED. 6 | type MagSafeLedState uint8 7 | 8 | // Representation of MagSafeLedState. 9 | const ( 10 | LEDSystem MagSafeLedState = 0x00 11 | LEDOff MagSafeLedState = 0x01 12 | LEDGreen MagSafeLedState = 0x03 13 | LEDOrange MagSafeLedState = 0x04 14 | LEDErrorOnce MagSafeLedState = 0x05 15 | LEDErrorPermSlow MagSafeLedState = 0x06 16 | LEDErrorPermFast MagSafeLedState = 0x07 17 | LEDErrorPermOff MagSafeLedState = 0x19 18 | ) 19 | 20 | // SetMagSafeLedState . 21 | func (c *AppleSMC) SetMagSafeLedState(state MagSafeLedState) error { 22 | logrus.Tracef("SetMagSafeLedState(%v) called", state) 23 | 24 | return c.Write(MagSafeLedKey, []byte{byte(state)}) 25 | } 26 | 27 | // GetMagSafeLedState . 28 | func (c *AppleSMC) GetMagSafeLedState() (MagSafeLedState, error) { 29 | logrus.Tracef("GetMagSafeLedState called") 30 | 31 | v, err := c.Read(MagSafeLedKey) 32 | if err != nil || len(v.Bytes) != 1 { 33 | return LEDOrange, err 34 | } 35 | 36 | rawState := MagSafeLedState(v.Bytes[0]) 37 | ret := LEDOrange 38 | switch rawState { 39 | case LEDOff, LEDGreen, LEDOrange, LEDErrorOnce, LEDErrorPermSlow: 40 | ret = rawState 41 | case 2: 42 | ret = LEDGreen 43 | } 44 | logrus.Tracef("GetMagSafeLedState returned %v", ret) 45 | return ret, nil 46 | } 47 | 48 | // CheckMagSafeExistence . 49 | func (c *AppleSMC) CheckMagSafeExistence() bool { 50 | _, err := c.Read(MagSafeLedKey) 51 | return err == nil 52 | } 53 | 54 | // SetMagSafeCharging . 55 | func (c *AppleSMC) SetMagSafeCharging(charging bool) error { 56 | state := LEDGreen 57 | if charging { 58 | state = LEDOrange 59 | } 60 | return c.SetMagSafeLedState(state) 61 | } 62 | 63 | // IsMagSafeCharging . 64 | func (c *AppleSMC) IsMagSafeCharging() (bool, error) { 65 | state, err := c.GetMagSafeLedState() 66 | 67 | return state == LEDOrange, err 68 | } 69 | -------------------------------------------------------------------------------- /pkg/smc/smc.go: -------------------------------------------------------------------------------- 1 | package smc 2 | 3 | import ( 4 | "github.com/charlie0129/gosmc" 5 | "github.com/sirupsen/logrus" 6 | ) 7 | 8 | // AppleSMC is a wrapper of gosmc.Connection. 9 | type AppleSMC struct { 10 | conn gosmc.Connection 11 | } 12 | 13 | // New returns a new AppleSMC. 14 | func New() *AppleSMC { 15 | return &AppleSMC{ 16 | conn: gosmc.New(), 17 | } 18 | } 19 | 20 | // NewMock returns a new mocked AppleSMC with prefill values. 21 | func NewMock(prefillValues map[string][]byte) *AppleSMC { 22 | conn := gosmc.NewMockConnection() 23 | 24 | for key, value := range prefillValues { 25 | err := conn.Write(key, value) 26 | if err != nil { 27 | panic(err) 28 | } 29 | } 30 | 31 | return &AppleSMC{ 32 | conn: conn, 33 | } 34 | } 35 | 36 | // Open opens the connection. 37 | func (c *AppleSMC) Open() error { 38 | return c.conn.Open() 39 | } 40 | 41 | // Close closes the connection. 42 | func (c *AppleSMC) Close() error { 43 | return c.conn.Close() 44 | } 45 | 46 | // Read reads a value from SMC. 47 | func (c *AppleSMC) Read(key string) (gosmc.SMCVal, error) { 48 | logrus.WithFields(logrus.Fields{ 49 | "key": key, 50 | }).Trace("Trying to read from SMC") 51 | 52 | v, err := c.conn.Read(key) 53 | if err != nil { 54 | return v, err 55 | } 56 | 57 | logrus.WithFields(logrus.Fields{ 58 | "key": key, 59 | "val": v, 60 | }).Trace("Read from SMC succeed") 61 | 62 | return v, nil 63 | } 64 | 65 | // Write writes a value to SMC. 66 | func (c *AppleSMC) Write(key string, value []byte) error { 67 | logrus.WithFields(logrus.Fields{ 68 | "key": key, 69 | "val": value, 70 | }).Trace("Trying to write to SMC") 71 | 72 | err := c.conn.Write(key, value) 73 | if err != nil { 74 | return err 75 | } 76 | 77 | logrus.WithFields(logrus.Fields{ 78 | "key": key, 79 | "val": value, 80 | }).Trace("Write to SMC succeed") 81 | 82 | return nil 83 | } 84 | -------------------------------------------------------------------------------- /pkg/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | var ( 4 | // Version . 5 | Version = "UNKNOWN" 6 | // GitCommit . 7 | GitCommit = "UNKNOWN" 8 | ) 9 | -------------------------------------------------------------------------------- /sleepcallback.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | /* 4 | #cgo LDFLAGS: -framework IOKit 5 | #include "hook.h" 6 | */ 7 | import "C" 8 | 9 | import ( 10 | "fmt" 11 | "time" 12 | 13 | "github.com/sirupsen/logrus" 14 | 15 | "github.com/charlie0129/batt/pkg/smc" 16 | ) 17 | 18 | var ( 19 | preSleepLoopDelaySeconds = 60 20 | postSleepLoopDelaySeconds = 30 21 | ) 22 | 23 | var ( 24 | lastWakeTime = time.Now() 25 | ) 26 | 27 | //export canSystemSleepCallback 28 | func canSystemSleepCallback() { 29 | /* Idle sleep is about to kick in. This message will not be sent for forced sleep. 30 | Applications have a chance to prevent sleep by calling IOCancelPowerChange. 31 | Most applications should not prevent idle sleep. 32 | 33 | Power Management waits up to 30 seconds for you to either allow or deny idle 34 | sleep. If you don't acknowledge this power change by calling either 35 | IOAllowPowerChange or IOCancelPowerChange, the system will wait 30 36 | seconds then go to sleep. 37 | */ 38 | logrus.Debugln("received kIOMessageCanSystemSleep notification, idle sleep is about to kick in") 39 | 40 | if !config.PreventIdleSleep { 41 | logrus.Debugln("PreventIdleSleep is disabled, allow idle sleep") 42 | C.AllowPowerChange() 43 | return 44 | } 45 | 46 | // We won't allow idle sleep if the system has just waked up, 47 | // because there may still be a maintain loop waiting (see the wg.Wait() in loop.go). 48 | // So decisions may not be made yet. We need to wait. 49 | // Actually, we wait the larger of preSleepLoopDelaySeconds and postSleepLoopDelaySeconds. This is not implemented yet. 50 | if timeAfterWokenUp := time.Since(lastWakeTime); timeAfterWokenUp < time.Duration(preSleepLoopDelaySeconds)*time.Second { 51 | logrus.Debugf("system has just waked up (%fs ago), deny idle sleep", timeAfterWokenUp.Seconds()) 52 | C.CancelPowerChange() 53 | return 54 | } 55 | 56 | // Run a loop immediately to update `maintainedChargingInProgress` variable. 57 | maintainLoopInner(false) 58 | 59 | if maintainedChargingInProgress { 60 | logrus.Debugln("maintained charging is in progress, deny idle sleep") 61 | C.CancelPowerChange() 62 | return 63 | } 64 | 65 | logrus.Debugln("no maintained charging is in progress, allow idle sleep") 66 | C.AllowPowerChange() 67 | } 68 | 69 | //export systemWillSleepCallback 70 | func systemWillSleepCallback() { 71 | /* The system WILL go to sleep. If you do not call IOAllowPowerChange or 72 | IOCancelPowerChange to acknowledge this message, sleep will be 73 | delayed by 30 seconds. 74 | 75 | NOTE: If you call IOCancelPowerChange to deny sleep it returns 76 | kIOReturnSuccess, however the system WILL still go to sleep. 77 | */ 78 | logrus.Debugln("received kIOMessageSystemWillSleep notification, system will go to sleep") 79 | 80 | if !config.DisableChargingPreSleep { 81 | logrus.Debugln("DisableChargingPreSleep is disabled, allow sleep") 82 | C.AllowPowerChange() 83 | return 84 | } 85 | 86 | // If charge limit is enabled (limit<100), no matter if maintained charging is in progress, 87 | // we disable charging just before sleep. 88 | // Previously, we only disabled charging if maintained charging was in progress. But we find 89 | // out this is not required, because if there is no maintained charging in progress, disabling 90 | // charging will not cause any problem. 91 | // By always disabling charging before sleep (if charge limit is enabled), we can prevent 92 | // some rare cases. 93 | if config.Limit < 100 { 94 | logrus.Infof("charge limit is enabled, disabling charging, and allowing sleep") 95 | // Delay next loop to prevent charging to be re-enabled after we disabled it. 96 | // macOS will wait 30s before going to sleep, there is a chance that a maintain loop is 97 | // executed during that time and it enables charging. 98 | // So we delay more than that, just to be sure. 99 | // No need to prevent duplicated runs. 100 | wg.Add(1) 101 | go func() { 102 | // Use sleep instead of time.After because when the computer sleeps, we 103 | // actually want the sleep to prolong as well. 104 | sleep(preSleepLoopDelaySeconds) 105 | wg.Done() 106 | }() 107 | err := smcConn.DisableCharging() 108 | if err != nil { 109 | logrus.Errorf("DisableCharging failed: %v", err) 110 | return 111 | } 112 | if config.ControlMagSafeLED { 113 | err = smcConn.SetMagSafeLedState(smc.LEDOff) 114 | if err != nil { 115 | logrus.Errorf("SetMagSafeLedState failed: %v", err) 116 | } 117 | } 118 | } else { 119 | logrus.Debugln("no maintained charging is in progress, allow sleep") 120 | } 121 | 122 | C.AllowPowerChange() 123 | } 124 | 125 | //export systemWillPowerOnCallback 126 | func systemWillPowerOnCallback() { 127 | // System has started the wake-up process... 128 | } 129 | 130 | //export systemHasPoweredOnCallback 131 | func systemHasPoweredOnCallback() { 132 | // System has finished waking up... 133 | logrus.Debugln("received kIOMessageSystemHasPoweredOn notification, system has finished waking up") 134 | lastWakeTime = time.Now() 135 | 136 | if config.Limit < 100 { 137 | logrus.Debugf("delaying next loop by %d seconds", postSleepLoopDelaySeconds) 138 | wg.Add(1) 139 | go func() { 140 | if config.DisableChargingPreSleep && config.ControlMagSafeLED { 141 | err := smcConn.SetMagSafeLedState(smc.LEDOff) 142 | if err != nil { 143 | logrus.Errorf("SetMagSafeLedState failed: %v", err) 144 | } 145 | } 146 | 147 | // Use sleep instead of time.After because when the computer sleeps, we 148 | // actually want the sleep to prolong as well. 149 | sleep(postSleepLoopDelaySeconds) 150 | 151 | wg.Done() 152 | }() 153 | } 154 | } 155 | 156 | // Use sleep instead of time.After or time.Sleep because when the computer sleeps, we 157 | // actually want the sleep to prolong as well. 158 | func sleep(seconds int) { 159 | tl := 250 // ms 160 | t := time.NewTicker(time.Duration(tl) * time.Millisecond) 161 | ticksWanted := seconds * 1000 / tl 162 | ticksElapsed := 0 163 | for range t.C { 164 | ticksElapsed++ 165 | if ticksElapsed > ticksWanted { 166 | break 167 | } 168 | } 169 | t.Stop() 170 | } 171 | 172 | func listenNotifications() error { 173 | logrus.Info("registered and listening system sleep notifications") 174 | if int(C.ListenNotifications()) != 0 { 175 | return fmt.Errorf("IORegisterForSystemPower failed") 176 | } 177 | return nil 178 | } 179 | 180 | func stopListeningNotifications() { 181 | C.StopListeningNotifications() 182 | logrus.Info("stopped listening system sleep notifications") 183 | } 184 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/gin-gonic/gin" 10 | "github.com/sirupsen/logrus" 11 | ) 12 | 13 | // Logger is the logrus logger handler 14 | func ginLogger(logger logrus.FieldLogger) gin.HandlerFunc { 15 | return func(c *gin.Context) { 16 | // other handler can change c.Path so: 17 | path := c.Request.URL.Path 18 | start := time.Now() 19 | c.Next() 20 | stop := time.Since(start) 21 | latency := int(math.Ceil(float64(stop.Nanoseconds()) / 1000000.0)) 22 | statusCode := c.Writer.Status() 23 | dataLength := c.Writer.Size() 24 | if dataLength < 0 { 25 | dataLength = 0 26 | } 27 | 28 | entry := logger.WithFields(logrus.Fields{ 29 | "statusCode": statusCode, 30 | "latency": latency, // time to process 31 | "method": c.Request.Method, 32 | "path": path, 33 | "dataLength": dataLength, 34 | }) 35 | 36 | if len(c.Errors) > 0 { 37 | entry.Error(c.Errors.ByType(gin.ErrorTypePrivate).String()) 38 | } else { 39 | msg := fmt.Sprintf("%s %s %d (%dms)", c.Request.Method, path, statusCode, latency) 40 | //nolint:gocritic 41 | if statusCode >= http.StatusInternalServerError { 42 | entry.Error(msg) 43 | } else if statusCode >= http.StatusBadRequest { 44 | entry.Warn(msg) 45 | } else { 46 | entry.Debug(msg) 47 | } 48 | } 49 | } 50 | } 51 | --------------------------------------------------------------------------------