diff options
| author | Franck Cuny <franck@fcuny.net> | 2025-09-06 13:20:32 -0700 |
|---|---|---|
| committer | Franck Cuny <franck@fcuny.net> | 2025-09-06 13:22:39 -0700 |
| commit | 0fb5af8a16909066c11dc282cf43373e721051dc (patch) | |
| tree | b68f9009c129f09e1f5b0d3592ef3cd70c3452e0 /cmd | |
| parent | certcheck to see x509 certification details (diff) | |
| download | x-0fb5af8a16909066c11dc282cf43373e721051dc.tar.gz | |
a utility to gathers detailed information about Apple Silicon System
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/apple-silicon-info/README.org | 15 | ||||
| -rw-r--r-- | cmd/apple-silicon-info/main.go | 281 | ||||
| -rw-r--r-- | cmd/apple-silicon-info/main_test.go | 197 |
3 files changed, 493 insertions, 0 deletions
diff --git a/cmd/apple-silicon-info/README.org b/cmd/apple-silicon-info/README.org new file mode 100644 index 0000000..682a11c --- /dev/null +++ b/cmd/apple-silicon-info/README.org @@ -0,0 +1,15 @@ +* socinfo + +A lightweight macOS utility that gathers detailed information about Apple Silicon System-on-Chip (SoC) specifications. + +** What it does + +=socinfo= queries your Mac's hardware through system utilities to retrieve: + +- CPU brand name and core configuration +- GPU core count +- Performance and efficiency core breakdown +- Thermal Design Power (TDP) specifications +- Memory bandwidth specifications + +The tool automatically detects your specific Apple Silicon variant (M1, M1 Pro/Max/Ultra, M2, M2 Pro/Max/Ultra, M3, M3 Pro/Max) and provides the corresponding technical specifications. diff --git a/cmd/apple-silicon-info/main.go b/cmd/apple-silicon-info/main.go new file mode 100644 index 0000000..f733d7e --- /dev/null +++ b/cmd/apple-silicon-info/main.go @@ -0,0 +1,281 @@ +package main + +import ( + "bufio" + "errors" + "fmt" + "os/exec" + "strconv" + "strings" +) + +// Custom error types +var ( + ErrParse = errors.New("socinfo parsing error") + ErrCommand = errors.New("command execution error") + ErrConvert = errors.New("conversion error") +) + +type ( + Watts = uint32 + Bandwidth = uint32 + CoreCount = uint16 +) + +// SocInfo represents information about the Silicon chip +type SocInfo struct { + CPUBrandName string `json:"cpu_brand_name"` + NumCPUCores CoreCount `json:"num_cpu_cores"` + NumGPUCores CoreCount `json:"num_gpu_cores"` + CPUMaxPower *Watts `json:"cpu_max_power,omitempty"` + GPUMaxPower *Watts `json:"gpu_max_power,omitempty"` + CPUMaxBW *Bandwidth `json:"cpu_max_bw,omitempty"` + GPUMaxBW *Bandwidth `json:"gpu_max_bw,omitempty"` + ECoreCount CoreCount `json:"e_core_count"` + PCoreCount CoreCount `json:"p_core_count"` +} + +// AppleChip represents different Apple Silicon variants +type AppleChip int + +const ( + M1 AppleChip = iota + M1Pro + M1Max + M1Ultra + M2 + M2Pro + M2Max + M2Ultra + M3 + M3Pro + M3Max + Unknown +) + +// ChipSpecs holds the specifications for each chip variant +type ChipSpecs struct { + CPUTDP Watts + GPUTDP Watts + CPUBW Bandwidth + GPUBW Bandwidth +} + +// fromBrandString determines the Apple chip type from brand string +func fromBrandString(brand string) AppleChip { + switch { + case strings.Contains(brand, "M1 Pro"): + return M1Pro + case strings.Contains(brand, "M1 Max"): + return M1Max + case strings.Contains(brand, "M1 Ultra"): + return M1Ultra + case strings.Contains(brand, "M1"): + return M1 + case strings.Contains(brand, "M2 Pro"): + return M2Pro + case strings.Contains(brand, "M2 Max"): + return M2Max + case strings.Contains(brand, "M2 Ultra"): + return M2Ultra + case strings.Contains(brand, "M2"): + return M2 + case strings.Contains(brand, "M3 Pro"): + return M3Pro + case strings.Contains(brand, "M3 Max"): + return M3Max + case strings.Contains(brand, "M3"): + return M3 + default: + return Unknown + } +} + +// getSpecs returns the specifications for the chip +func (chip AppleChip) getSpecs() ChipSpecs { + switch chip { + case M1: + return ChipSpecs{CPUTDP: 20, GPUTDP: 20, CPUBW: 70, GPUBW: 70} + case M1Pro: + return ChipSpecs{CPUTDP: 30, GPUTDP: 30, CPUBW: 200, GPUBW: 200} + case M1Max: + return ChipSpecs{CPUTDP: 30, GPUTDP: 60, CPUBW: 250, GPUBW: 400} + case M1Ultra: + return ChipSpecs{CPUTDP: 60, GPUTDP: 120, CPUBW: 500, GPUBW: 800} + case M2: + return ChipSpecs{CPUTDP: 25, GPUTDP: 15, CPUBW: 100, GPUBW: 100} + case M2Pro: + return ChipSpecs{CPUTDP: 30, GPUTDP: 35, CPUBW: 0, GPUBW: 0} + case M2Max: + return ChipSpecs{CPUTDP: 30, GPUTDP: 40, CPUBW: 0, GPUBW: 0} + default: + return ChipSpecs{CPUTDP: 0, GPUTDP: 0, CPUBW: 0, GPUBW: 0} + } +} + +// SystemCommand interface for testable command execution +type SystemCommand interface { + Execute(binary string, args ...string) ([]byte, error) +} + +// RealCommand implements SystemCommand for actual system calls +type RealCommand struct{} + +func (r *RealCommand) Execute(binary string, args ...string) ([]byte, error) { + cmd := exec.Command(binary, args...) + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrCommand, err) + } + return output, nil +} + +const sysctlPath = "/usr/sbin/sysctl" + +// getCPUInfo retrieves CPU information using sysctl +func getCPUInfo(cmd SystemCommand) (string, CoreCount, CoreCount, CoreCount, error) { + args := []string{ + "-n", // don't display the variable name + "machdep.cpu.brand_string", + "machdep.cpu.core_count", + "hw.perflevel0.logicalcpu", + "hw.perflevel1.logicalcpu", + } + + output, err := cmd.Execute(sysctlPath, args...) + if err != nil { + return "", 0, 0, 0, err + } + + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + if len(lines) < 4 { + return "", 0, 0, 0, fmt.Errorf("%w: insufficient output lines", ErrParse) + } + + cpuBrandName := lines[0] + + numCPUCores, err := strconv.ParseUint(lines[1], 10, 16) + if err != nil { + return "", 0, 0, 0, fmt.Errorf("%w: parsing cpu cores: %v", ErrConvert, err) + } + + numPerformanceCores, err := strconv.ParseUint(lines[2], 10, 16) + if err != nil { + return "", 0, 0, 0, fmt.Errorf("%w: parsing performance cores: %v", ErrConvert, err) + } + + numEfficiencyCores, err := strconv.ParseUint(lines[3], 10, 16) + if err != nil { + return "", 0, 0, 0, fmt.Errorf("%w: parsing efficiency cores: %v", ErrConvert, err) + } + + return cpuBrandName, CoreCount( + numCPUCores, + ), CoreCount( + numPerformanceCores, + ), CoreCount( + numEfficiencyCores, + ), nil +} + +// getGPUInfo retrieves GPU information using system_profiler +func getGPUInfo(cmd SystemCommand) (CoreCount, error) { + args := []string{"-detailLevel", "basic", "SPDisplaysDataType"} + + output, err := cmd.Execute("/usr/sbin/system_profiler", args...) + if err != nil { + return 0, err + } + + scanner := bufio.NewScanner(strings.NewReader(string(output))) + for scanner.Scan() { + line := scanner.Text() + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "Total Number of Cores") { + parts := strings.Split(trimmed, ": ") + if len(parts) == 2 { + cores, err := strconv.ParseUint(strings.TrimSpace(parts[1]), 10, 16) + if err != nil { + return 0, fmt.Errorf("%w: parsing gpu cores: %v", ErrConvert, err) + } + return CoreCount(cores), nil + } + } + } + + if err := scanner.Err(); err != nil { + return 0, fmt.Errorf("%w: scanning output: %v", ErrParse, err) + } + + return 0, fmt.Errorf("%w: GPU core count not found", ErrParse) +} + +// NewSocInfo creates a new SocInfo instance by gathering system information +func NewSocInfo() (*SocInfo, error) { + cmd := &RealCommand{} + return NewSocInfoWithCommand(cmd) +} + +// NewSocInfoWithCommand creates a new SocInfo instance with a custom command executor (for testing) +func NewSocInfoWithCommand(cmd SystemCommand) (*SocInfo, error) { + cpuBrandName, numCPUCores, eCoreCount, pCoreCount, err := getCPUInfo(cmd) + if err != nil { + return nil, err + } + + numGPUCores, err := getGPUInfo(cmd) + if err != nil { + return nil, err + } + + chip := fromBrandString(cpuBrandName) + specs := chip.getSpecs() + + var cpuMaxPower, gpuMaxPower *Watts + var cpuMaxBW, gpuMaxBW *Bandwidth + + if specs.CPUTDP > 0 { + cpuMaxPower = &specs.CPUTDP + } + if specs.GPUTDP > 0 { + gpuMaxPower = &specs.GPUTDP + } + if specs.CPUBW > 0 { + cpuMaxBW = &specs.CPUBW + } + if specs.GPUBW > 0 { + gpuMaxBW = &specs.GPUBW + } + + return &SocInfo{ + CPUBrandName: cpuBrandName, + NumCPUCores: numCPUCores, + NumGPUCores: numGPUCores, + CPUMaxPower: cpuMaxPower, + GPUMaxPower: gpuMaxPower, + CPUMaxBW: cpuMaxBW, + GPUMaxBW: gpuMaxBW, + ECoreCount: eCoreCount, + PCoreCount: pCoreCount, + }, nil +} + +func main() { + socInfo, err := NewSocInfo() + if err != nil { + fmt.Printf("Error getting SoC info: %v\n", err) + return + } + + var cpuPower Watts + if socInfo.CPUMaxPower != nil { + cpuPower = *socInfo.CPUMaxPower + } + + fmt.Printf("Our CPU is an %s, and we have %d CPU cores, and %d GPU cores. The TDP is %d.\n", + socInfo.CPUBrandName, + socInfo.NumCPUCores, + socInfo.NumGPUCores, + cpuPower, + ) +} diff --git a/cmd/apple-silicon-info/main_test.go b/cmd/apple-silicon-info/main_test.go new file mode 100644 index 0000000..a9b7caf --- /dev/null +++ b/cmd/apple-silicon-info/main_test.go @@ -0,0 +1,197 @@ +package main + +import ( + "errors" + "testing" +) + +type MockCommand struct { + output string + err error +} + +func NewMockCommand(output string) *MockCommand { + return &MockCommand{output: output} +} + +func NewMockCommandWithError(err error) *MockCommand { + return &MockCommand{err: err} +} + +func (m *MockCommand) Execute(binary string, args ...string) ([]byte, error) { + if m.err != nil { + return nil, m.err + } + return []byte(m.output), nil +} + +func TestGetGPUInfo(t *testing.T) { + mockOutput := `Graphics/Displays: + Apple M2: + Total Number of Cores: 10` + + cmd := NewMockCommand(mockOutput) + + result, err := getGPUInfo(cmd) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + expected := CoreCount(10) + if result != expected { + t.Errorf("Expected %d GPU cores, got %d", expected, result) + } +} + +func TestGetGPUInfoNotFound(t *testing.T) { + mockOutput := `Graphics/Displays: + Apple M2: + Some other field: 10` + + cmd := NewMockCommand(mockOutput) + + _, err := getGPUInfo(cmd) + if err == nil { + t.Fatal("Expected error when GPU cores not found, got nil") + } +} + +func TestGetCPUInfoSuccess(t *testing.T) { + mockOutput := "Apple M2\n8\n4\n4\n" + cmd := NewMockCommand(mockOutput) + + brand, cores, pCores, eCores, err := getCPUInfo(cmd) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if brand != "Apple M2" { + t.Errorf("Expected brand 'Apple M2', got '%s'", brand) + } + if cores != 8 { + t.Errorf("Expected 8 cores, got %d", cores) + } + if pCores != 4 { + t.Errorf("Expected 4 performance cores, got %d", pCores) + } + if eCores != 4 { + t.Errorf("Expected 4 efficiency cores, got %d", eCores) + } +} + +func TestGetCPUInfoMissingCoreCount(t *testing.T) { + mockOutput := "Apple M2\n" + cmd := NewMockCommand(mockOutput) + + _, _, _, _, err := getCPUInfo(cmd) + if err == nil { + t.Fatal("Expected error with insufficient output, got nil") + } +} + +func TestGetCPUInfoInvalidCoreCount(t *testing.T) { + mockOutput := "Apple M2\ninvalid\n4\n4\n" + cmd := NewMockCommand(mockOutput) + + _, _, _, _, err := getCPUInfo(cmd) + if err == nil { + t.Fatal("Expected error with invalid core count, got nil") + } +} + +func TestFromBrandString(t *testing.T) { + tests := []struct { + brand string + expected AppleChip + }{ + {"Apple M1", M1}, + {"Apple M1 Pro", M1Pro}, + {"Apple M1 Max", M1Max}, + {"Apple M1 Ultra", M1Ultra}, + {"Apple M2", M2}, + {"Apple M2 Pro", M2Pro}, + {"Apple M2 Max", M2Max}, + {"Apple M2 Ultra", M2Ultra}, + {"Apple M3", M3}, + {"Apple M3 Pro", M3Pro}, + {"Apple M3 Max", M3Max}, + {"Intel Core i7", Unknown}, + } + + for _, test := range tests { + result := fromBrandString(test.brand) + if result != test.expected { + t.Errorf("For brand '%s', expected %v, got %v", test.brand, test.expected, result) + } + } +} + +func TestChipGetSpecs(t *testing.T) { + tests := []struct { + chip AppleChip + expected ChipSpecs + }{ + {M1, ChipSpecs{CPUTDP: 20, GPUTDP: 20, CPUBW: 70, GPUBW: 70}}, + {M1Pro, ChipSpecs{CPUTDP: 30, GPUTDP: 30, CPUBW: 200, GPUBW: 200}}, + {M2, ChipSpecs{CPUTDP: 25, GPUTDP: 15, CPUBW: 100, GPUBW: 100}}, + {Unknown, ChipSpecs{CPUTDP: 0, GPUTDP: 0, CPUBW: 0, GPUBW: 0}}, + } + + for _, test := range tests { + result := test.chip.getSpecs() + if result != test.expected { + t.Errorf("For chip %v, expected %+v, got %+v", test.chip, test.expected, result) + } + } +} + +func TestNewSocInfoWithCommand(t *testing.T) { + sysctlOutput := "Apple M2\n8\n4\n4\n" + + profilerOutput := `Graphics/Displays: + Apple M2: + Total Number of Cores: 10` + + cmd := &MockCommandMultiple{ + outputs: map[string]string{ + "/usr/sbin/sysctl": sysctlOutput, + "/usr/sbin/system_profiler": profilerOutput, + }, + } + + socInfo, err := NewSocInfoWithCommand(cmd) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if socInfo.CPUBrandName != "Apple M2" { + t.Errorf("Expected CPU brand 'Apple M2', got '%s'", socInfo.CPUBrandName) + } + if socInfo.NumCPUCores != 8 { + t.Errorf("Expected 8 CPU cores, got %d", socInfo.NumCPUCores) + } + if socInfo.NumGPUCores != 10 { + t.Errorf("Expected 10 GPU cores, got %d", socInfo.NumGPUCores) + } + if socInfo.CPUMaxPower == nil || *socInfo.CPUMaxPower != 25 { + t.Errorf("Expected CPU max power 25W, got %v", socInfo.CPUMaxPower) + } +} + +type MockCommandMultiple struct { + outputs map[string]string + err error +} + +func (m *MockCommandMultiple) Execute(binary string, args ...string) ([]byte, error) { + if m.err != nil { + return nil, m.err + } + + output, exists := m.outputs[binary] + if !exists { + return nil, errors.New("binary not found in mock") + } + + return []byte(output), nil +} |
